Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .impeccable/config.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"detector": {
"ignoreRules": [],
"ignoreFiles": [],
"ignoreFiles": [
".stitch/designs/*"
],
"ignoreValues": [
{
"rule": "overused-font",
Expand Down
1 change: 1 addition & 0 deletions .okf/build/test-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ verified: { by: claude/fable-5, at: 2026-08-01T11:30:00Z }
| `bin/rake test:critical` | Critical Minitest suite (34 runs / 53 screenshots), ~81s host / ~46s Docker since the 2026-08-01 skip_area fix | At component/task milestones and before every commit outside sprint micro-commit trains |
| `bin/test` | Visual regression on the host (baselines in `macos/` on a Mac; on Linux, comparable to `linux/` when run through `bin/setup-test-env`'s pinned stack) | ONCE at PR prep (branch head, before `gh pr create`) or on Paul's explicit confirmation - NOT per commit (Paul 2026-07-31: qtest is the routine gate) |
| `bin/dtest` | Same suite in Linux/Docker (baselines in `linux/`) - CI runs Linux | Same trigger as bin/test; a PR must never open without this leg (green-locally / red-in-CI otherwise) |
| `bin/check-post-visuals` | Ratchet: counts blog posts over 800 words with no mermaid/SVG/image. Fails when the count exceeds `FLOOR` (78 as of 2026-08-13) | Before publishing any post. Added 2026-08-13 - `diagram_rendering_test.rb` only proves diagrams RENDER, nothing proved posts HAVE them, and 25 of the 31 long posts published since 2026-04 shipped with none |

`bin/qtest` page keys mirror `themes/beaver/assets/css/pages/*.css` basenames
AND `critical/<name>-critical.css` basenames - the two sets differ (e.g.
Expand Down
49 changes: 49 additions & 0 deletions .okf/design/mermaid-theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ tags: [mermaid, fonts, design]
generated:
by: process:okf-migrate
at: 2026-07-13T00:00:00Z
verified: { by: claude/opus-5, at: 2026-08-13T10:00:00Z }
---

# Root cause worth remembering
Expand All @@ -33,6 +34,54 @@ last-character clipping. The fix in `baseof.html`:
`themes/beaver/layouts/_markup/render-codeblock-mermaid.html`, which sets
`features.mermaid`.

# The unrendered-source flash (fixed 2026-08-13)

`<div class="mermaid">` renders its own SOURCE as article prose until
mermaid.js swaps in the SVG - and because `baseof.html` defers `mermaid.run()`
until `document.fonts.ready`, that window is as long as the Caveat load takes.
If the font never resolves, the wall of `flowchart TD ... classDef dead
fill:#fff5f5,...` never goes away.

Reported by the owner as "images have `&nbsp;` on it" - the `&nbsp;` we append
to labels is emitted by Hugo as `&amp;nbsp;`, so it sits in that raw text as a
literal entity. The entity itself is FINE once rendered: htmlLabels is on, so
mermaid decodes it to a trailing space. The bug was only ever the flash.

Fix is one rule in `themes/beaver/assets/css/single-post.css` - mermaid stamps
`data-processed` on each node it renders:

```css
.mermaid:not([data-processed]) { display: none; }
```

Trade-off accepted: if JS fails outright the diagram is absent rather than
showing its source. Absent beats a wall of code in the middle of a paragraph.
This was site-wide and pre-existing - it shipped with the first mermaid post in
May 2026 and went unreported for three months because it only bites on a slow
font load.

# Mobile legibility: width comes from COLUMNS, not label length

Mermaid wraps node labels at ~200px, so a long label never widens a diagram -
the number of PARALLEL COLUMNS does. Three sibling nodes feeding one gate
renders ~800px wide. Because the SVG scales to the content column, wide means
small: `renderedPx = minFontSize * (displayedWidth / viewBoxWidth)`. At a
390px viewport a 797px-wide diagram renders 8.88px text, under the 9px floor
`bin/check-svg-floor` enforces for course SVGs.

Measured 2026-08-13 while backfilling diagrams into the August blog cluster:

| Shape | viewBox | rendered @390px |
|---|---|---|
| 3 siblings -> 1 gate (fan-in) | 797px | 8.88px - FAILS |
| 2 columns (chain + branch) | 545px | 13.0px |
| single vertical column | 272px | 20px (unscaled) |

Fix by chaining vertically (`A --> B --> C`, or `~~~` for unconnected nodes),
not by shrinking labels. Note the trade: a pure single column renders at full
size on mobile but sits as a narrow ribbon in the 684px desktop content
column. Two columns is usually the balance point.

# Brand rule

Handwritten typography in diagrams is a non-negotiable brand identity -
Expand Down
59 changes: 59 additions & 0 deletions .okf/log.md
Original file line number Diff line number Diff line change
Expand Up @@ -1164,3 +1164,62 @@ calls → pause, re-open A + C." Clock starts at first post or first send.
names" was unsatisfiable; "2-3 past-client referral asks" survives because
a referral source need not be the ICP. Demote, don't delete, when a weaker
form retains value.

## 2026-08-13 - Blog in-body visuals: a gate nothing enforced

Audited the four posts published 2026-08-07/08 (Kamal multi-server, Lovable->Rails
migration, Rails 7 EOL, CVE-2026-66066). The prose gates had all held: zero em
dashes, zero bold inline-header lists, 2-8% paragraph cap breaks against the
39-58% of the failed TDD/XP cluster, varied opener shapes, and every mechanical
pre-publish check green.

The gap was visuals. Three of four had none, and the archive said why: only 2
posts in all of 2026 carried an in-body visual, while 25 of the 31 long posts
published since 2026-04 carried none. CLAUDE.md's cognitive-load gate requires a
hero visual over 800 words; nothing checked it, because
`test/unit/diagram_rendering_test.rb` proves diagrams RENDER, not that posts HAVE
them.

**Durable rules:**
1. **Text gates automate, visual gates rot.** Banned words and frontmatter shape
are greppable so they survive time pressure; "does this need a diagram" is
judgment and silently drops. Any gate stated only in prose will decay - give
it a ratchet script or expect it unenforced.
2. **Ratchet over backfill.** 81 legacy posts are over the line and nobody is
backfilling them. `bin/check-post-visuals` fails only when the count RISES,
so new work is blocked while the backlog burns down at its own pace - same
shape as `bin/check-svg-floor`.
3. **Read the OKF bundle before authoring diagrams, not after.** Skipping the
session-start consume meant shipping five diagrams with diamond decision
nodes, which `design/mermaid-theme.md` bans outright. Caught only on the
pre-commit maintain pass.
4. **A cover chip is a claim with an expiry.** The Rails 7 EOL cover read "7.2
SUPPORT ENDS August 9, 2026" and the post was written in future tense; both
went stale four days after publishing. Date-bearing covers and prose need a
recheck whenever the date passes.
5. **The gem never varies.** `.stitch/design.md` slot 4 is explicit - the
low-poly ruby is the brand throughline across ALL Ruby/Rails posts.
"Cover sameness" is the design working, not a defect.

## 2026-08-13 - Mermaid rendered its own source as prose until fonts loaded

Owner reported "images have &nbsp; on it" on the Rails 7 EOL post. Reproduced by
stalling `document.fonts.ready`: `<div class="mermaid">` displays its raw source
as article text - `flowchart TD R70["Rails 7.0 - ...&nbsp;"] ... classDef dead
fill:#fff5f5,...` - because `baseof.html` defers `mermaid.run()` until fonts
resolve and no CSS hid the div in the meantime. Fixed with one rule:
`.mermaid:not([data-processed]) { display: none; }`.

**Durable rules:**
1. **The reported symptom is rarely the bug.** "`&nbsp;` in the image" was not an
entity-escaping bug - the entity decodes correctly once htmlLabels render it.
It was a flash-of-unrendered-source bug that happened to expose the entity.
Chasing the escaping would have produced a fix that changed nothing.
2. **Deferring render needs a matching hide.** Any "wait for X, then swap the
DOM" pattern leaves the pre-swap content visible for the length of X. The
font-gated `mermaid.run()` was added to fix label CLIPPING and silently
created a worse visual defect on the slow path.
3. **Site-wide and three months old.** It shipped with the first mermaid post in
May 2026 and nobody saw it, because it only bites on a slow font load - the
visual suite renders after fonts settle, so screenshots were always green.
A suite that waits for the happy path cannot see the unhappy one.
38 changes: 38 additions & 0 deletions bin/check-post-visuals
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# CLAUDE.md's cognitive-load gate wants a hero visual in the first fold for
# posts over 800 words. Nothing enforced it: diagram_rendering_test.rb checks
# that diagrams RENDER, not that posts HAVE them. 25 of the 31 long posts
# published since 2026-04 shipped with no visual at all.
#
# Ratchet, not a clean gate - 81 legacy posts are already over the line and
# nobody is backfilling them. The count may only go down, so a new post
# without a visual fails the build while the backlog burns off at its own pace.
#
# Usage: bin/check-post-visuals [dir] (default: content/blog)
# POST_VISUALS_LIST=1 to print the full burn-down list.

FLOOR = 78
MIN_WORDS = 800
root = ARGV[0] || "content/blog"

fails = Dir.glob(File.join(root, "*", "index.md")).sort.filter_map do |path|
body = File.read(path).split(/^---$/, 3)[2].to_s
words = body.split.size
next if words < MIN_WORDS
next if body.match?(/^```mermaid|<svg|^!\[/)

[path, words]
end

if fails.size > FLOOR
puts "post-visuals: #{fails.size} posts over #{MIN_WORDS} words have no visual (floor #{FLOOR})."
puts "Add a diagram/image, or if you removed one deliberately, raise FLOOR with a reason."
fails.last(10).each { |p, w| puts format(" %5dw %s", w, p) }
exit 1
end

puts "post-visuals: #{fails.size} of #{Dir.glob(File.join(root, '*', 'index.md')).size} posts lack a visual (floor #{FLOOR})"
fails.each { |p, w| puts format(" %5dw %s", w, p) } if ENV["POST_VISUALS_LIST"] == "1"
puts "post-visuals: floor is loose, lower FLOOR to #{fails.size}" if fails.size < FLOOR
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,33 @@ Once workers live on their own hosts, queue concurrency and retry behavior stop

Next, Kamal stages assets and SSL certs onto every host in a separate pass. Only then does booting start, and one detail here saves you a confused hour: non-primary roles wait. Kamal creates a health barrier, and job hosts block until the first web container reports healthy - the log says `Waiting for the first healthy web container before booting job on 10.0.0.21...`. If that first web container never turns healthy, your job hosts never boot at all. The image gets tagged `latest` only after every host is up.

```mermaid
flowchart TD
B["Build image once,<br/>push to registry&nbsp;"]
P["Pull it on every app host&nbsp;"]
S["Stage assets and SSL certs<br/>on every host&nbsp;"]
W["Boot web hosts<br/>proxy polls /up for 30s&nbsp;"]
BAR["First web container<br/>healthy?&nbsp;"]
J["Boot job hosts<br/>no proxy, so Docker HEALTHCHECK<br/>or a 7s readiness_delay&nbsp;"]
STUCK["Job hosts never boot&nbsp;"]
TAG["Tag the image latest&nbsp;"]

B --> P --> S --> W --> BAR
BAR -->|"yes&nbsp;"| J
J --> TAG
BAR -->|"never&nbsp;"| STUCK

classDef step fill:#faf7f2,stroke:#555,stroke-width:2px,color:#1a1a1a
classDef gate fill:#f5e9ff,stroke:#7c3aed,stroke-width:2.5px,color:#1a1a1a
classDef bad fill:#fff5f5,stroke:#cc342d,stroke-width:2.5px,color:#1a1a1a
classDef ok fill:#f0f9f0,stroke:#2e7d32,stroke-width:2.5px,color:#1a1a1a

class B,P,S,W,J step
class BAR gate
class STUCK bad
class TAG ok
```

Health checks also differ by role. Web containers get polled by kamal-proxy on `/up` once a second until `deploy_timeout` (default 30s). Job containers have no proxy, so Kamal uses the Docker `HEALTHCHECK` from your image - and if there isn't one, it waits `readiness_delay` (7 seconds) and declares victory. A worker that crashes at second 8 still counts as a successful deploy, which is worth fixing with a real `HEALTHCHECK` before you trust rolling deploys. When a deploy stalls on "target failed to become healthy", [we wrote a full debugging guide for that error](/blog/solving-kamals-target-failed-become-healthy/).

One more thing lives in exactly one place: the deploy lock, held over SSH on the primary host only. If the primary host is down, you can't take the lock, which means you can't deploy the healthy hosts either. [2.12.0](https://github.com/basecamp/kamal/releases/tag/v2.12.0) added `--lock-wait` for the other lock problem - two CI runs racing each other - which pairs well with [deploying from GitHub Actions](/blog/automate-your-deployments-with-kamal-2-github-actions-devops-development/).
Expand Down
26 changes: 26 additions & 0 deletions content/blog/migrate-lovable-replit-app-to-rails/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,32 @@ Here's how it usually splits:

One decision drives everything else. If the schema is sane and the data is real, you're doing a backend transplant and keeping the UI. If the schema is a mess and the "logic" is a thin wrapper over AI-generated CRUD, you're rebuilding, and the old app is a spec, not a codebase.

```mermaid
flowchart TD
START["Dump the schema,<br/>read it before deciding&nbsp;"]
Q["Schema sane,<br/>data real?&nbsp;"]
T["Backend transplant:<br/>port schema and rows,<br/>point the React UI at Rails&nbsp;"]
R["Rebuild in Rails:<br/>load the rows, treat the<br/>old app as the spec&nbsp;"]
BOTH["Either way you rebuild<br/>auth and payments&nbsp;"]

START --> Q
Q -->|"yes&nbsp;"| T
Q -->|"no, it is thin CRUD&nbsp;"| R
T --> BOTH
R --> BOTH

classDef step fill:#faf7f2,stroke:#555,stroke-width:2px,color:#1a1a1a
classDef gate fill:#f5e9ff,stroke:#7c3aed,stroke-width:2.5px,color:#1a1a1a
classDef keep fill:#f0f9f0,stroke:#2e7d32,stroke-width:2.5px,color:#1a1a1a
classDef redo fill:#fff5f5,stroke:#cc342d,stroke-width:2.5px,color:#1a1a1a

class START step
class Q gate
class T keep
class R redo
class BOTH redo
```

We wrote a longer field guide to that call in [the vibe coding crisis: AI code debt](/blog/vibe-coding-crisis-ai-code-debt/). Read the schema before you decide. Everything downstream forks here.

This is also the point where you decide whether to run the migration yourself. If your team reads Postgres comfortably and has shipped auth before, keep going. If not, [our vibe code rescue service](/services/vibe-code-rescue/) opens with a 48-hour audit and quotes a fixed price from it.
Expand Down
Binary file modified content/blog/rails-7-eol-unpatched-security-exposure/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 25 additions & 2 deletions content/blog/rails-7-eol-unpatched-security-exposure/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,31 @@ cover_image_alt: "Rails 7 end of life cover - no patch for CVE-2026-66066 on 7.1
canonical_url: https://jetthoughts.com/blog/rails-7-eol-unpatched-security-exposure/
---

On July 29 the Rails security team fixed a CVSSv4 9.5 vulnerability in Active Storage - an unauthenticated upload that reads files off your server and [may enable code execution](https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm). Three branches got a patched release that day: 7.2, 8.0, and 8.1. One of those three, 7.2, reaches the end of its security support on August 9, 2026 - eleven days after carrying the patch, upstream stops shipping it fixes. Rails 7.1 and everything below it got nothing.
On July 29 the Rails security team fixed a CVSSv4 9.5 vulnerability in Active Storage - an unauthenticated upload that reads files off your server and [may enable code execution](https://github.com/rails/rails/security/advisories/GHSA-xr9x-r78c-5hrm). Three branches got a patched release that day: 7.2, 8.0, and 8.1. One of those three, 7.2, reached the end of its security support on August 9, 2026 - eleven days after carrying the patch, upstream stopped shipping it fixes. Rails 7.1 and everything below it got nothing.

That's the concrete version of Rails 7 end of life. Rails 7.1 had [finished its security support period](https://rubyonrails.org/2025/10/29/new-rails-releases-and-end-of-support-announcement) by October 2025, so when CVE-2026-66066 landed, the backport list stopped at 7.2.

If your app sits on 7.1 or lower and processes image variants, there's no version on your branch to bump to. The fix lives on branches you're not on.

```mermaid
flowchart TD
R70["Rails 7.0 - no patch, dead since Apr 2025&nbsp;"]
R71["Rails 7.1 - no patch, dead since Oct 2025&nbsp;"]
R72["Rails 7.2 - patched, then dead Aug 9, 2026&nbsp;"]
R80["Rails 8.0 - patched, supported to Nov 2026&nbsp;"]
R81["Rails 8.1 - patched, supported to Oct 2027&nbsp;"]

R70 ~~~ R71 ~~~ R72 ~~~ R80 ~~~ R81

classDef dead fill:#fff5f5,stroke:#cc342d,stroke-width:2.5px,color:#1a1a1a
classDef soon fill:#fffbe6,stroke:#bf8a00,stroke-width:2px,color:#1a1a1a
classDef live fill:#f0f9f0,stroke:#2e7d32,stroke-width:2.5px,color:#1a1a1a

class R70,R71,R72 dead
class R80 soon
class R81 live
```

The CVE itself already has a [full writeup](/blog/rails-cve-2026-66066-active-storage-rce/): mechanism, detection inside a running app, the libvips 8.13 requirement, the secret rotation order. This post covers the problem that outlives this CVE. You're on an unsupported Rails, the next critical will skip you the same way, and there are four ways out.

## Skipped on schedule
Expand Down Expand Up @@ -72,7 +91,7 @@ Last, sort that list by whether this specific CVE can reach each app. The [CVE w

Upgrade if you can. Every other exit on this list buys time and leaves the branch dead.

Pick the target with the support clock in view. 7.2 is the smallest hop from 7.1, but its [security support ends August 9, 2026](https://rubyonrails.org/2025/10/29/new-rails-releases-and-end-of-support-announcement), so on its own it trades one dead branch for another; paired with the paid 7.2 LTS line in exit 2 it holds up. 8.0 holds until November 7, 2026, three months away.
Pick the target with the support clock in view. 7.2 is the smallest hop from 7.1, but its [security support ended August 9, 2026](https://rubyonrails.org/2025/10/29/new-rails-releases-and-end-of-support-announcement), so on its own it now trades one dead branch for another; paired with the paid 7.2 LTS line in exit 2 it still holds up. 8.0 holds until November 7, 2026.

8.1 is the only hop that buys real time - security fixes run to October 2027. Budget the Ruby bump into the same plan: Rails 8 needs Ruby 3.2 or newer, and a 7.1 app often isn't there yet.

Expand Down Expand Up @@ -123,6 +142,10 @@ Match each app from your fleet sweep to a row - a mixed portfolio lands in sever
| Internal tool behind SSO, no public uploads | Exit 4 now, fold exit 1 into next quarter's maintenance |
| App with a real decommission date | Exit 4, with the date and the accepted risk written down |

One app you can match to a row yourself. A dozen of them across three dead branches, against a roadmap and a budget that are already committed, is a sequencing call someone senior has to make and then defend when it pushes a quarter of feature work.

If nobody holds that seat today, it's the [fractional CTO](/services/fractional-cto/) work we get called into, and it starts with the same fleet sweep you just ran. Worth saying plainly: that engagement ranks the queue and owns the argument, it doesn't write your upgrade commits.

## Put the dates where you'll see them

The support deadlines from exit 1 belong in the calendar, each with an owner - treat them like TLS certificate expiries, not facts you rediscover inside an advisory email.
Expand Down
Loading
Loading