<%= message.role %>
- <% if message.role == "assistant" && message.tool_calls.any? %>
+ <% if message.role == "assistant" && message.tool_call? %>
<%= tool_call_pill_text(message) %>
<% end %>
<% if message.content.to_s.strip.present? %>
diff --git a/bin/verify-model-registry b/bin/verify-model-registry
index e3fa96d..245b1d9 100755
--- a/bin/verify-model-registry
+++ b/bin/verify-model-registry
@@ -29,11 +29,13 @@ offered = LLM::Stages::AVAILABLE_MODELS.keys
failures = []
# RubyLLM wires config.model_registry_store to the ruby_llm_models table from an
-# `on_load :active_record` hook, so in a lazy-loading environment the store is
-# still nil until something touches ActiveRecord — and RubyLLM::Models would
-# silently resolve against the gem's bundled JSON instead of the DB, which is
-# the exact confusion this script exists to prevent.
-Rails.application.eager_load!
+# `on_load :active_record` hook. This script boots via require "config/environment"
+# rather than `bin/rails runner`, and that path does not touch ActiveRecord — so
+# without the line below the store is nil and RubyLLM::Models would silently
+# resolve against the gem's bundled JSON instead of the DB, which is the exact
+# confusion this script exists to prevent. Referencing ActiveRecord::Base fires
+# the hook on its own; a full eager_load! is not needed.
+ActiveRecord::Base.connection_pool
store = RubyLLM.config.model_registry_store
puts "registry store rows: #{store ? store.count : "(no store configured)"}"
diff --git a/docs/09-ideas/05-followups.md b/docs/09-ideas/05-followups.md
index 8b292b1..d9a63e6 100644
--- a/docs/09-ideas/05-followups.md
+++ b/docs/09-ideas/05-followups.md
@@ -161,6 +161,45 @@ The codegen agent now runs each revision in a per-instruction throwaway containe
---
+## 2026-08-28
+
+### RubyLLM v2 migration leftovers — both need their own migration
+
+Two residues of the gem-generated upgrade migration
+(`db/migrate/20260822224622_add_ruby_llm_v2_0_columns.rb`). Neither is fixable
+by editing that migration — it has already run — so each needs a new one, and
+neither was in scope for the upgrade itself.
+
+- **Dead index on the hottest-written table.** The migration converts
+ `messages.model_id` from an integer FK into `provider` + `model_id` strings
+ and indexes them as `index_messages_on_provider_and_model_id`. But v2 never
+ writes those columns: `ChatMethods#message_attributes` assigns only
+ thinking/citations/server_tool_calls/raw_content/raw_reasoning/finish_reason/
+ cache_until_here, and per-message model identity moved to `ruby_llm_usages`.
+ Measured locally after the upgrade: 241 rows carry `provider` (all from the
+ one-time backfill) and **all 45 written since have it NULL**. So the index is
+ pure insert overhead on the table that takes one row per streamed message.
+ Dropping it — and the columns — is safe only after confirming nothing reads
+ them; the backfilled values are the sole historical record of which model
+ answered a pre-upgrade message, so consider whether that is worth keeping
+ before dropping the columns as well.
+ Also leftover: `index_ruby_llm_tool_calls_on_message_id` survives the rename
+ alongside the new `(message_type, message_id)` composite, which fully covers
+ it — the gem only ever queries with `message_type` present.
+
+- **The `tool_calls → messages` foreign key is gone with nothing replacing it.**
+ `normalize_tool_calls` drops it because the column became polymorphic, so
+ `ruby_llm_tool_calls.message_id` is now a bare integer. Today this is
+ defence-in-depth only: the `Project → chat → messages → ruby_llm_tool_calls`
+ `dependent: :destroy` chain still cleans up. It matters if a future cleanup
+ job or console fix ever uses `delete_all`, because an orphan whose provider
+ tool-call id later recurs would hit the UNIQUE index on `tool_call_id` and
+ make `persist_tool_calls` raise `RecordNotUnique` **inside**
+ `persist_message_completion`'s transaction, rolling back the assistant
+ message. A polymorphic FK can't be restored directly; the options are a
+ periodic orphan sweep or a partial constraint. Worth deciding before writing
+ any code that deletes messages outside AR callbacks.
+
## 2026-06-17
### Move untrusted previews to a separate registrable domain
From 44d51f96306069b56e13d587fcd27361dbb0f234 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pawe=C5=82=20Strza=C5=82kowski?=
Date: Wed, 2 Sep 2026 21:30:31 +0200
Subject: [PATCH 10/13] Close the remaining routes out of Tool#execute
The is_a?(Hash) guard from 328c7c9 covered the top level only. A valid
JSON object whose `revisions` hold non-objects cleared it and raised one
line later: NoMethodError for String/Integer/nil#fetch, TypeError for
Array#fetch, since Array(hash) reaches the map as [[k, v]]. Neither is
rescued, and neither is caught by RubyLLM's orphan cleanup, which only
fires for RubyLLM/Faraday/Timeout errors. The result is the state
CLAUDE.md calls unrecoverable: a persisted tool_use with no tool_result,
after which every message in that chat is rejected.
Reject non-object elements in both planners, and add a StandardError
backstop at both #execute boundaries. The backstop is the point: a
rescue list cannot be complete here. `revisions.create!` raises
RecordInvalid on a blank summary or prompt, which no shape check in
build_result can prevent.
This inverts the propagation test. Letting an error escape was what
orphaned the tool_use, and ChatRespondJob already rescues StandardError,
so the exception never reached an operator anyway - it only killed the
chat. It is reported via Rails.error instead of swallowed.
---
.../plan_application_creation/ad_hoc_llm.rb | 6 +++++
.../ad_hoc_llm.rb | 6 +++++
app/tools/create_application.rb | 9 +++++++
app/tools/modify_application.rb | 9 +++++++
.../ad_hoc_llm_test.rb | 26 ++++++++++++++++++-
.../ad_hoc_llm_test.rb | 26 ++++++++++++++++++-
test/tools/create_application_test.rb | 20 ++++++++++----
test/tools/modify_application_test.rb | 20 ++++++++++----
8 files changed, 110 insertions(+), 12 deletions(-)
diff --git a/app/services/plan_application_creation/ad_hoc_llm.rb b/app/services/plan_application_creation/ad_hoc_llm.rb
index 6e9161a..1ddd98d 100644
--- a/app/services/plan_application_creation/ad_hoc_llm.rb
+++ b/app/services/plan_application_creation/ad_hoc_llm.rb
@@ -35,6 +35,12 @@ def self.build_result(content)
raise InvalidResponse, "expected a JSON object, got #{content.class}" unless content.is_a?(Hash)
revisions = Array(content["revisions"]).map do |r|
+ # The Hash check above only covers the top level. A valid JSON object
+ # whose `revisions` hold non-objects raises NoMethodError (String#fetch)
+ # or TypeError (Array#fetch, via Array(hash) => [[k, v]]) here — neither
+ # of which #execute rescues, and both of which orphan the tool_use.
+ raise InvalidResponse, "expected revision objects, got #{r.class}" unless r.is_a?(Hash)
+
{ summary: r.fetch("summary"), prompt: r.fetch("prompt") }
end
raise InvalidResponse, "empty revisions" if revisions.empty?
diff --git a/app/services/plan_application_modification/ad_hoc_llm.rb b/app/services/plan_application_modification/ad_hoc_llm.rb
index e44e319..611dcd6 100644
--- a/app/services/plan_application_modification/ad_hoc_llm.rb
+++ b/app/services/plan_application_modification/ad_hoc_llm.rb
@@ -35,6 +35,12 @@ def self.build_result(content)
raise InvalidResponse, "expected a JSON object, got #{content.class}" unless content.is_a?(Hash)
revisions = Array(content["revisions"]).map do |r|
+ # The Hash check above only covers the top level. A valid JSON object
+ # whose `revisions` hold non-objects raises NoMethodError (String#fetch)
+ # or TypeError (Array#fetch, via Array(hash) => [[k, v]]) here — neither
+ # of which #execute rescues, and both of which orphan the tool_use.
+ raise InvalidResponse, "expected revision objects, got #{r.class}" unless r.is_a?(Hash)
+
{ summary: r.fetch("summary"), prompt: r.fetch("prompt") }
end
raise InvalidResponse, "empty revisions" if revisions.empty?
diff --git a/app/tools/create_application.rb b/app/tools/create_application.rb
index 2419182..9576f01 100644
--- a/app/tools/create_application.rb
+++ b/app/tools/create_application.rb
@@ -70,6 +70,15 @@ def execute(intent:, clarifications: {})
# silently kept the String. Nothing may escape #execute — an exception here
# leaves a persisted tool_use with no tool_result and the chat is finished.
{ error: "Could not generate a plan: #{e.message}. Ask the user to rephrase." }
+ rescue StandardError => e
+ # Backstop for the same invariant. A rescue list cannot be complete: the
+ # revisions loop can raise NoMethodError/TypeError on an off-schema plan,
+ # and `revisions.create!` can raise RecordInvalid on a blank summary or
+ # prompt. RubyLLM's own orphan cleanup does not cover any of these — it
+ # only fires for RubyLLM/Faraday/Timeout errors — so anything reaching
+ # here would kill the chat permanently. Report it rather than swallow it.
+ Rails.error.report(e, handled: true, context: { project_id: @project.id })
+ { error: "Could not generate a plan. Ask the user to rephrase." }
end
private
diff --git a/app/tools/modify_application.rb b/app/tools/modify_application.rb
index 8c918cf..8e3e8b4 100644
--- a/app/tools/modify_application.rb
+++ b/app/tools/modify_application.rb
@@ -70,6 +70,15 @@ def execute(intent:, clarifications: {})
# silently kept the String. Nothing may escape #execute — an exception here
# leaves a persisted tool_use with no tool_result and the chat is finished.
{ error: "Could not generate a modification plan: #{e.message}. Ask the user to rephrase." }
+ rescue StandardError => e
+ # Backstop for the same invariant. A rescue list cannot be complete: the
+ # revisions loop can raise NoMethodError/TypeError on an off-schema plan,
+ # and `revisions.create!` can raise RecordInvalid on a blank summary or
+ # prompt. RubyLLM's own orphan cleanup does not cover any of these — it
+ # only fires for RubyLLM/Faraday/Timeout errors — so anything reaching
+ # here would kill the chat permanently. Report it rather than swallow it.
+ Rails.error.report(e, handled: true, context: { project_id: @project.id })
+ { error: "Could not generate a modification plan. Ask the user to rephrase." }
end
private
diff --git a/test/services/plan_application_creation/ad_hoc_llm_test.rb b/test/services/plan_application_creation/ad_hoc_llm_test.rb
index 176bc6d..102f986 100644
--- a/test/services/plan_application_creation/ad_hoc_llm_test.rb
+++ b/test/services/plan_application_creation/ad_hoc_llm_test.rb
@@ -2,7 +2,9 @@
class PlanApplicationCreation::AdHocLLMTest < ActiveSupport::TestCase
# Stubs invoke_llm so the test drives build_result with fixture content directly,
- # mimicking what RubyLLM returns from chat.with_schema(...).ask(...).content.
+ # mimicking what RubyLLM returns from chat.with_schema(...).ask(...).parsed --
+ # v2's .content is the raw String; .parsed is the decoded Hash, and it RAISES
+ # on malformed JSON where v1 silently kept the String.
def with_llm_response(content)
captured = {}
original = PlanApplicationCreation::AdHocLLM.method(:invoke_llm)
@@ -88,6 +90,28 @@ def plan_fixture(name)
end
end
+ # The is_a?(Hash) guard above is TOP-LEVEL only. Each of these parses to an
+ # object and clears it, then raises inside the revisions map: NoMethodError
+ # for String/Integer/nil#fetch, TypeError for Array#fetch (a Hash reaches the
+ # map as [[k, v]] via Array()). Neither is rescued by #execute, so each one
+ # would orphan the tool_use and kill the chat permanently.
+ test "raises InvalidResponse when revisions hold non-objects" do
+ [
+ [ "Add a Cart model" ],
+ [ 42 ],
+ [ nil ],
+ "oops",
+ { "summary" => "a", "prompt" => "b" }
+ ].each do |revisions|
+ content = { "instruction_description" => "d", "revisions" => revisions }
+ with_llm_response(content) do
+ assert_raises(PlanApplicationCreation::AdHocLLM::InvalidResponse, "expected #{revisions.inspect} to be rejected") do
+ PlanApplicationCreation::AdHocLLM.call(intent: "x", clarifications: {}, context: {}, openrouter_api_key: "sk-or-test", model: "anthropic/claude-haiku-4.5")
+ end
+ end
+ end
+ end
+
test "raises InvalidResponse when revisions array is empty" do
with_llm_response(plan_fixture("empty_revisions.json")) do
assert_raises(PlanApplicationCreation::AdHocLLM::InvalidResponse) do
diff --git a/test/services/plan_application_modification/ad_hoc_llm_test.rb b/test/services/plan_application_modification/ad_hoc_llm_test.rb
index 6a4a497..5c22015 100644
--- a/test/services/plan_application_modification/ad_hoc_llm_test.rb
+++ b/test/services/plan_application_modification/ad_hoc_llm_test.rb
@@ -2,7 +2,9 @@
class PlanApplicationModification::AdHocLLMTest < ActiveSupport::TestCase
# Stubs invoke_llm so the test drives build_result with fixture content directly,
- # mimicking what RubyLLM returns from chat.with_schema(...).ask(...).content.
+ # mimicking what RubyLLM returns from chat.with_schema(...).ask(...).parsed --
+ # v2's .content is the raw String; .parsed is the decoded Hash, and it RAISES
+ # on malformed JSON where v1 silently kept the String.
def with_llm_response(content)
captured = {}
original = PlanApplicationModification::AdHocLLM.method(:invoke_llm)
@@ -88,6 +90,28 @@ def plan_fixture(name)
end
end
+ # The is_a?(Hash) guard above is TOP-LEVEL only. Each of these parses to an
+ # object and clears it, then raises inside the revisions map: NoMethodError
+ # for String/Integer/nil#fetch, TypeError for Array#fetch (a Hash reaches the
+ # map as [[k, v]] via Array()). Neither is rescued by #execute, so each one
+ # would orphan the tool_use and kill the chat permanently.
+ test "raises InvalidResponse when revisions hold non-objects" do
+ [
+ [ "Add a Cart model" ],
+ [ 42 ],
+ [ nil ],
+ "oops",
+ { "summary" => "a", "prompt" => "b" }
+ ].each do |revisions|
+ content = { "instruction_description" => "d", "revisions" => revisions }
+ with_llm_response(content) do
+ assert_raises(PlanApplicationModification::AdHocLLM::InvalidResponse, "expected #{revisions.inspect} to be rejected") do
+ PlanApplicationModification::AdHocLLM.call(intent: "x", clarifications: {}, context: {}, openrouter_api_key: "sk-or-test", model: "anthropic/claude-haiku-4.5")
+ end
+ end
+ end
+ end
+
test "raises InvalidResponse when revisions array is empty" do
with_llm_response(plan_fixture("empty_revisions.json")) do
assert_raises(PlanApplicationModification::AdHocLLM::InvalidResponse) do
diff --git a/test/tools/create_application_test.rb b/test/tools/create_application_test.rb
index d39d044..2c544b4 100644
--- a/test/tools/create_application_test.rb
+++ b/test/tools/create_application_test.rb
@@ -132,16 +132,26 @@ def stub_create_plan(result_or_proc)
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
- test "on unexpected error from PlanApplicationCreation: propagates and persists nothing" do
+ # Was: assert_raises(RuntimeError). Propagation is precisely what orphans the
+ # tool_use -- and ChatRespondJob:30 rescues StandardError anyway, so the
+ # exception never reached an operator; it only killed the chat. #execute now
+ # reports and returns an error hash so the tool_use still gets a tool_result.
+ test "on unexpected error from PlanApplicationCreation: reports it, returns an error hash, persists nothing" do
raising = ->(**) { raise RuntimeError, "upstream boom" }
+ result = nil
- assert_no_difference -> { Instruction.count } do
- assert_no_difference -> { Revision.count } do
- stub_create_plan(raising) do
- assert_raises(RuntimeError) { @tool.execute(intent: "x", clarifications: {}) }
+ reports = capture_error_reports(RuntimeError) do
+ assert_no_difference -> { Instruction.count } do
+ assert_no_difference -> { Revision.count } do
+ stub_create_plan(raising) do
+ result = @tool.execute(intent: "x", clarifications: {})
+ end
end
end
end
+
+ assert_match(/Could not generate a plan/, result[:error])
+ assert_equal [ "upstream boom" ], reports.map { |r| r.error.message }
end
test "refuses and persists nothing when an implementing instruction already exists" do
diff --git a/test/tools/modify_application_test.rb b/test/tools/modify_application_test.rb
index 8d3b1a3..9574843 100644
--- a/test/tools/modify_application_test.rb
+++ b/test/tools/modify_application_test.rb
@@ -148,16 +148,26 @@ def stub_planner(result_or_proc)
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
- test "on unexpected error from PlanApplicationModification: propagates and persists nothing" do
+ # Was: assert_raises(RuntimeError). Propagation is precisely what orphans the
+ # tool_use -- and ChatRespondJob:30 rescues StandardError anyway, so the
+ # exception never reached an operator; it only killed the chat. #execute now
+ # reports and returns an error hash so the tool_use still gets a tool_result.
+ test "on unexpected error from PlanApplicationModification: reports it, returns an error hash, persists nothing" do
raising = ->(**) { raise RuntimeError, "upstream boom" }
+ result = nil
- assert_no_difference -> { Instruction.count } do
- assert_no_difference -> { Revision.count } do
- stub_planner(raising) do
- assert_raises(RuntimeError) { @tool.execute(intent: "x", clarifications: {}) }
+ reports = capture_error_reports(RuntimeError) do
+ assert_no_difference -> { Instruction.count } do
+ assert_no_difference -> { Revision.count } do
+ stub_planner(raising) do
+ result = @tool.execute(intent: "x", clarifications: {})
+ end
end
end
end
+
+ assert_match(/Could not generate a modification plan/, result[:error])
+ assert_equal [ "upstream boom" ], reports.map { |r| r.error.message }
end
test "refuses and persists nothing when an implementing instruction already exists" do
From f263727df714457488c92f7011d82bcab679e78b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pawe=C5=82=20Strza=C5=82kowski?=
Date: Wed, 2 Sep 2026 21:30:38 +0200
Subject: [PATCH 11/13] Fail the registry check on a store that shadows the
bundle
The script passed on the exact state it exists to catch. With an empty
ruby_llm_models table every id resolves from the gem's bundled registry,
so it printed "registry store rows: 0" and then "all ids resolve".
But find_or_create_model writes one row per successful resolution, so a
fresh environment self-poisons: the first chat leaves a single row, and
from the next boot that row is the whole registry, because a non-empty
store shadows the bundle completely. That is the 2026-08-12 failure.
Warn when the store is empty, fail when it holds fewer rows than the
offered ids. Runbook 03 said an id had to be "resolvable from one of the
two", which invites the false converse; state that the store wins
whenever it has any rows at all.
Also note that refresh! now holds the write lock for 1464 rows rather
than 410, against the live production database.
---
bin/verify-model-registry | 23 ++++++++++++++++++++++-
docs/05-runbooks/03-llm-model-registry.md | 14 +++++++++++---
2 files changed, 33 insertions(+), 4 deletions(-)
diff --git a/bin/verify-model-registry b/bin/verify-model-registry
index 245b1d9..8cc322c 100755
--- a/bin/verify-model-registry
+++ b/bin/verify-model-registry
@@ -27,6 +27,7 @@ require_relative "../config/environment"
candidates = ARGV
offered = LLM::Stages::AVAILABLE_MODELS.keys
failures = []
+store_underpopulated = false
# RubyLLM wires config.model_registry_store to the ruby_llm_models table from an
# `on_load :active_record` hook. This script boots via require "config/environment"
@@ -37,10 +38,27 @@ failures = []
# the hook on its own; a full eager_load! is not needed.
ActiveRecord::Base.connection_pool
store = RubyLLM.config.model_registry_store
+rows = store ? store.count : 0
-puts "registry store rows: #{store ? store.count : "(no store configured)"}"
+puts "registry store rows: #{store ? rows : "(no store configured)"}"
puts "resolved registry: #{RubyLLM::Models.instance.all.size}"
+# Three states, only one of which is healthy. A NON-EMPTY store shadows the
+# bundled registry completely, and find_or_create_model writes one row per
+# successful resolution -- so a fresh environment self-poisons: the first chat
+# leaves exactly one row, and from the next boot that row IS the registry.
+# Resolving everything from the bundle is therefore not a pass, it is a warning.
+if rows.zero?
+ puts "\nWARNING: the store is empty, so the ids below resolve from the gem's"
+ puts "bundled registry. The first chat will write ONE row, and that row will"
+ puts "then shadow the bundle entirely. Run RubyLLM.models.refresh! before use."
+elsif rows < offered.size
+ puts "\nFAIL: the store holds #{rows} row(s) but #{offered.size} ids are offered."
+ puts "A non-empty store shadows the bundle, so ids missing from it raise"
+ puts "ModelNotFoundError even when the bundle carries them."
+ store_underpopulated = true
+end
+
def check(id, failures)
info, = RubyLLM::Models.resolve(id)
puts format(" OK %-32s provider=%-11s ctx=%s", id, info.provider, info.context_window || "?")
@@ -59,6 +77,9 @@ end
if failures.any?
puts "\n#{failures.size} id(s) did not resolve: #{failures.join(', ')}"
+end
+
+if failures.any? || store_underpopulated
puts "Populate the registry with: bin/rails runner 'RubyLLM.models.refresh!'"
puts "Then restart long-lived processes — the registry is memoized per process."
exit 1
diff --git a/docs/05-runbooks/03-llm-model-registry.md b/docs/05-runbooks/03-llm-model-registry.md
index 25c2d63..931a6bc 100644
--- a/docs/05-runbooks/03-llm-model-registry.md
+++ b/docs/05-runbooks/03-llm-model-registry.md
@@ -12,8 +12,11 @@ The gem's railtie wires `config.model_registry_store` to
`models.json` **only when the table is empty** (`ruby_llm/models.rb:85-97`,
logging *"Model registry store is empty, falling back to the registry file"*).
-So an id has to be resolvable from one of the two: the store, or the bundle. A
-model absent from both raises `ModelNotFoundError`.
+So the store wins whenever it holds any rows at all: a **non-empty store
+shadows the bundle completely**, and an id missing from it raises
+`ModelNotFoundError` even when the bundle carries that id. The bundle only ever
+serves a store that is entirely empty — which is also why a fresh environment
+self-poisons, since each successful resolution writes one row back.
Affected stages are the four RubyLLM-backed ones (chat, plan_creation,
plan_modification, template). `code` and `docs` are unaffected: they pass the id
@@ -59,6 +62,11 @@ provider fetch is what populates the ids.
kamal app exec --reuse "bin/rails runner 'puts RubyLLM.config.model_registry_store.count'"
# 2. Populate
+# Run this during a quiet window: refresh! wraps every row in ONE transaction,
+# and v2 fetches every provider's registry (1464 rows, not v1's 410). That holds
+# the write lock on production.sqlite3 while users are chatting, and
+# config/database.yml sets timeout: 5000 — a chat write that waits longer than
+# 5s raises SQLite3::BusyException.
kamal app exec --reuse "bin/rails runner 'RubyLLM.models.refresh!; puts RubyLLM.config.model_registry_store.count'"
# 3. Verify. bin/verify-model-registry only exists in the image once it has been
@@ -67,7 +75,7 @@ kamal app exec --reuse "bin/verify-model-registry"
kamal app exec --reuse "bin/rails runner 'LLM::Stages::AVAILABLE_MODELS.keys.each { |id| begin; RubyLLM::Models.resolve(id); puts %(OK #{id}); rescue => e; puts %(FAIL #{id} #{e.class}); end }'"
# 4. Restart — required; see the warning below
-V=$(kamal app version | tail -1)
+V=$(kamal app version | sed -n '2p') # line 2: kamal prints "App Host:" first, and tail -1 is blank
kamal app stop
kamal app start --version="$V"
```
From c7a68002a728fd369e6aaaab3de064fd3c30f041 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pawe=C5=82=20Strza=C5=82kowski?=
Date: Wed, 2 Sep 2026 21:30:49 +0200
Subject: [PATCH 12/13] Make the v2 rollout runbook safe to execute as written
Five gaps, each one a step someone following the procedure would get
wrong:
- `kamal app version | tail -1` returns a BLANK line, because kamal
prints "App Host: " first and ends with a blank. An empty
--version is not .presence, so Kamal falls through to the local git
SHA - during a rollback, the v2 image. The documented rollback booted
v2 against the restored v1 database and re-ran the one-way migration
over the snapshot, and the site came back up either way. Take line 2,
and verify code and data after restoring.
- `kamal deploy` alone leaves the old v1 container serving while the new
one migrates, against tables just renamed: "no such table:
tool_calls" on every project page, and with SOLID_QUEUE_IN_PUMA a v1
job writing dropped columns can orphan a tool_use. Stop the app first,
after pre-building so the outage is only boot plus migrate.
- A failed deploy stops the NEW container and leaves the old one routed,
with the migration already committed - the one state neither image can
read. Say so, and check rather than assuming nothing happened.
- The rehearsal produced no go/no-go: nothing timed the migration
against kamal's 30s default, and nothing checked that
backfill_usage_entries carried every row before
remove_legacy_message_columns dropped the source.
- The secrets warning sat on `kamal app boot`, but `kamal deploy`
re-sources .kamal/secrets identically, and a prefix check cannot tell
a stale key from a live one. Compare hashes against production before
deploying.
Also split the two mutually exclusive rollback commands, since a
block-level copy-paste ran both, and clear jobs enqueued under v2 - the
queue is a separate database that the restore does not touch, and
discard_on DeserializationError is commented out.
---
docs/05-runbooks/04-ruby-llm-v2-rollout.md | 115 +++++++++++++++++++--
1 file changed, 107 insertions(+), 8 deletions(-)
diff --git a/docs/05-runbooks/04-ruby-llm-v2-rollout.md b/docs/05-runbooks/04-ruby-llm-v2-rollout.md
index d55861e..d17bc51 100644
--- a/docs/05-runbooks/04-ruby-llm-v2-rollout.md
+++ b/docs/05-runbooks/04-ruby-llm-v2-rollout.md
@@ -69,10 +69,30 @@ sqlite3 storage/development.sqlite3 ".backup 'storage/development.sqlite3.mine'"
cp /tmp/production.sqlite3.pre-v2 storage/development.sqlite3
rm -f storage/development.sqlite3-wal storage/development.sqlite3-shm
-# 1.5 Record the before count, migrate, record the after count.
+# 1.5 Record the before counts, migrate (TIMED), record the after counts.
sqlite3 storage/development.sqlite3 "SELECT COUNT(*) FROM models;" # before
-bin/rails db:migrate
-sqlite3 storage/development.sqlite3 "SELECT COUNT(*) FROM ruby_llm_models;" # after — MUST match
+sqlite3 storage/development.sqlite3 \
+ "SELECT COUNT(*) FROM messages WHERE input_tokens IS NOT NULL
+ OR output_tokens IS NOT NULL OR cache_read_tokens IS NOT NULL
+ OR cache_write_tokens IS NOT NULL OR thinking_tokens IS NOT NULL;" # before
+
+time bin/rails db:migrate
+# -> This is the go/no-go number. Kamal's deploy_timeout defaults to 30s and
+# config/deploy.yml does not override it, so a migration slower than that
+# is reported as a FAILED deploy while still running on production. On
+# SQLite every remove_column/rename_column goes through alter_table, which
+# copies the whole table twice; `messages` is hit 12 times, so runtime
+# scales with roughly 24x its row count, not 1x. Over ~10s here: set
+# deploy_timeout in config/deploy.yml before deploying.
+
+sqlite3 storage/development.sqlite3 "SELECT COUNT(*) FROM ruby_llm_models;" # after — MUST match
+sqlite3 storage/development.sqlite3 "SELECT COUNT(*) FROM ruby_llm_usages;" # after — MUST match
+# -> The usages count MUST equal the token-column count above.
+# backfill_usage_entries SKIPS any message whose provider/model_id are
+# null and whose chat has no ruby_llm_model_id, and
+# remove_legacy_message_columns then drops the source columns. A shortfall
+# is silent, irreversible data loss and nothing else in this runbook
+# catches it.
# 1.6 Structural checks. Both must be clean.
sqlite3 storage/development.sqlite3 "PRAGMA integrity_check; PRAGMA foreign_key_check;"
@@ -99,17 +119,45 @@ token data. `ruby_llm_batches` is created empty.
> registry for every provider (410 → 1464 locally on first refresh). If the count
> changed, attribute it to whichever step you just ran. See runbook 03.
-If step 1.5's two numbers differ, or 1.6 reports anything, **stop** — do not
+If either of step 1.5's paired counts differ, or 1.6 reports anything, **stop** — do not
deploy. Production has data shapes the rehearsal just found and the plan did not.
## 2. Before deploying
```bash
+# 2.0 Check your shell FIRST. Both `kamal deploy` and `kamal app boot`
+# re-source .kamal/secrets from the caller's environment (`kamal app start`
+# does not — it reuses the container's env). SMTP_PASSWORD and
+# GITHUB_CLIENT_SECRET come from your shell, so a local dev export ships
+# silently to production. This has happened (2026-05-15).
+#
+# Prefixes cannot tell a stale key from a live one — every Resend key
+# starts `re_` — so compare hashes against what production runs now.
+for v in SMTP_PASSWORD GITHUB_CLIENT_SECRET; do
+ mine=$(printenv "$v" | shasum | cut -c1-8)
+ live=$(kamal app exec --reuse "sh -c 'printenv $v | shasum | cut -c1-8'" | sed -n '2p')
+ echo "$v shell=$mine prod=$live"
+done
+# -> MUST match. A mismatch means your shell would overwrite production's
+# value. Fix your environment before going further.
+
# 2.1 Capture the currently-running version. You need this to roll back, and
# it is unavailable once the new one is running.
-kamal app version | tail -1 # write it down — call it PREV
+#
+# Take line 2, not `tail -1`: kamal prints "App Host: " first and ends
+# with a blank line, so `tail -1` returns EMPTY. An empty --version is not
+# `.presence`, so Kamal falls through to the LOCAL git SHA — which during a
+# rollback is the v2 image, and booting that against a restored v1 database
+# re-runs the one-way migration over the snapshot you just restored.
+PREV=$(kamal app version | sed -n '2p')
+[ -n "$PREV" ] || echo "FAILED to capture the running version — do not deploy"
+echo "PREV=$PREV" # write this down as well; $PREV dies with your shell
```
+> Any pipe applied to **kamal's own stdout** has to skip the `App Host:` line.
+> Pipes *inside* a quoted container command (`kamal app exec --reuse "... | tail -3"`)
+> run in the container and are unaffected.
+
```bash
# 2.2 Check for chats parked mid-tool-round. backfill_tool_results moves
# messages.tool_call_id onto ruby_llm_tool_calls.result_id and then drops
@@ -130,10 +178,32 @@ they are.
kamal app exec --reuse \
"sqlite3 /rails/storage/production.sqlite3 \".backup '/rails/storage/production.sqlite3.pre-v2'\""
-# 3.2 Deploy. db:prepare runs the migration during boot.
+# 3.2 Pre-build, so the outage below is boot+migrate rather than
+# build+push+boot+migrate.
+kamal build push
+
+# 3.3 STOP the app before anything migrates. `kamal deploy` on its own boots
+# the new container, migrates, and only routes traffic once it is healthy —
+# leaving the OLD v1 container live against tables the migration has just
+# renamed. In that window `GET /projects/:id` raises
+# "no such table: tool_calls" on every project page, and because
+# SOLID_QUEUE_IN_PUMA is true a v1 ChatRespondJob writes dropped columns;
+# if its assistant(tool_use) already persisted, the tool_result never lands
+# and that chat is permanently dead. A planned minute of downtime is
+# cheaper. Do not skip this step to save it.
+kamal app stop
+
+# 3.4 Deploy. db:prepare runs the migration during boot, with nothing attached.
kamal deploy
-# 3.3 Confirm the migration ran cleanly rather than assuming it did.
+# 3.5 If `kamal deploy` exited NON-ZERO, do NOT assume nothing happened. Kamal
+# stops the NEW container on a failed healthcheck, but the migration has
+# already committed — leaving the pre-upgrade image on a v2 schema, the one
+# state neither image can read. Check before deciding, and go to section 5
+# if it ran.
+kamal app exec --reuse "bin/rails db:migrate:status | tail -3"
+
+# 3.6 Confirm the migration ran cleanly rather than assuming it did.
kamal app logs --lines 100
# 3.4 Confirm the schema and the registry on the running container.
@@ -178,8 +248,37 @@ ssh root@77.42.95.154 "docker run --rm -v hifumi_dev_storage:/s alpine sh -c \
# 5.3 Bring back the pre-upgrade version (PREV, from step 2.1). db:prepare
# finds the v1 schema and no pending migration, so it is a no-op.
+# 5.3 Bring back the pre-upgrade version (PREV, from step 2.1). An EMPTY
+# --version resolves to the local git SHA, i.e. the v2 image, which would
+# re-run the one-way migration over the database you just restored.
+[ -n "$PREV" ] || echo "refusing: PREV is unset — recover it before continuing"
kamal app start --version="$PREV" # preferred: reuses the existing container
-kamal app boot --version="$PREV" # only if that container is gone
+```
+
+Only if that container is gone — note the secrets warning below, which applies
+to `boot` but not to `start`:
+
+```bash
+kamal app boot --version="$PREV"
+```
+
+```bash
+# 5.4 Confirm you are on v1 code AND v1 data. Without this the failure mode in
+# 5.3 is silent: the site comes back up either way.
+kamal app exec --reuse "bin/rails db:migrate:status | tail -3"
+# -> 20260822224622 MUST read "down"
+kamal app exec --reuse \
+ "sqlite3 /rails/storage/production.sqlite3 'SELECT COUNT(*) FROM models;'"
+# -> MUST succeed. "no such table: models" means you re-migrated.
+
+# 5.5 Discard jobs enqueued under v2. Solid Queue lives in its own database
+# (config/database.yml), so the restore above did not touch it, and those
+# jobs now point at rows the restored primary does not have. They will not
+# discard themselves — `discard_on ActiveJob::DeserializationError` is
+# commented out in app/jobs/application_job.rb — they pile into
+# solid_queue_failed_executions instead.
+kamal app exec --reuse \
+ "bin/rails runner 'SolidQueue::Job.where(finished_at: nil).delete_all'"
```
> ⚠️ **Do not restore via `kamal app exec --reuse`.** It needs a running
From 3f869679e1b866479d727f73e45de322c097d43f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pawe=C5=82=20Strza=C5=82kowski?=
Date: Wed, 2 Sep 2026 21:30:57 +0200
Subject: [PATCH 13/13] Cover the tool-call visibility branch and correct the
canon
visible_in_chat?'s tool_call? disjunct had no test: the one case that
puts a tool call on a message gives it prose too, so content.present?
short-circuits first. The shape it exists for is the empty-content
build-started message, and a regression would silently hide every pill
behind message_row_class's "hidden".
CLAUDE.md cited instructions.txt.erb:18 for the tool-idempotency rule,
which is on 19 - and since the tool-side guard went in 13e9c1c, that
bullet is the only description of the invariant left. Its Phase 2 note
also quoted a pill string that no longer exists and the same deleted
guard.
Note in plan_schema_test.rb that CI's eager_load, not the test, is what
guards the require "schematist": under parallelize any test touching
CreateApplication loads RubyLLM::Tool, which requires schematist for the
rest of the process.
Also record messages.content_raw as a third migration residual, and file
the 2026-08-28 section in date order so it isn't hidden mid-file.
---
CLAUDE.md | 4 +--
docs/09-ideas/05-followups.md | 52 ++++++++++++++++++--------------
test/models/message_test.rb | 20 ++++++++++++
test/schemas/plan_schema_test.rb | 8 +++++
4 files changed, 60 insertions(+), 24 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 4871aed..6368b12 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,7 +14,7 @@ Hosted at **[hifumi.dev](https://hifumi.dev)** · Source: this repo.
- **RubyLLM v2 upgrade (2026-08-23)**: `ruby_llm` is now a **git pin** — `crmne/ruby_llm@c45ebd78`, the unreleased 2.0 line, which still reports `VERSION 1.16.0` because the bump happens at release. Pinned rather than tracking `main` because `main` moves daily and `BUNDLE_DEPLOYMENT=1` plus `HIFUMI_AGENT_IMAGE` reusing this image make the resolved revision a reviewed decision; swap to a version constraint once 2.0 ships to RubyGems. RubyLLM now owns four tables, all via one **irreversible** migration: `ruby_llm_models` and `ruby_llm_tool_calls` are the old `models` / `tool_calls` renamed in place, while `ruby_llm_usages` (backfilled from token columns the migration then drops from `messages`) and `ruby_llm_batches` are created fresh. `Schematist::Schema` replaced `RubyLLM::Schema`, and structured output reads `.parsed` rather than `.content`. Rollout procedure: `docs/05-runbooks/04-ruby-llm-v2-rollout.md`.
Deferred observations from Phase 2 (revisit later, not blockers):
-- refused-tool-call pill UX (Step 6) — the `🌀 Starting generation…` flash when the LLM ignores the state rule and Phase 5's tool guard rescues.
+- refused-tool-call pill UX (Step 6) — the `🌀 Build started` pill flashing when the LLM ignores the state rule. Nothing rescues it any more: the tool-side guard was deleted in `13e9c1c`, leaving the prompt rule and the `BadRequestError` banner (see the RubyLLM idempotency bullet below).
- deferred-request handling after `✅ Generation finished.` — see `docs/09-ideas/02-deferred-request-handling.md`.
- Step 7 wall-time margin (Step 7) — real run consumed ~900s vs the spike's 496s; the integration test's `WALL_TIME_BUDGET = 900` sits right at the edge. Bump the budget or investigate W2-phase slowdown (looks heavy on the docs-update agent) before relying on this in CI.
@@ -57,4 +57,4 @@ Additional sources from the Phase 1 spike:
- **LLM model selection**: never hardcode a model id at a call site — `lib/llm/stages.rb` (`LLM::Stages`, note the `LLM` acronym inflection) is the registry of the six LLM stages (chat, plan_creation, plan_modification, template, code, docs), their labels, factory defaults, and the curated `AVAILABLE_MODELS` list (full OpenRouter ids only, never `sonnet`/`haiku` aliases). Profiles store per-user defaults (`default__model`), projects snapshot their own selection (`_model`) at creation; selectors live in the build tab (`model_selections/_pane`), the new-project form, and the account integrations pane. A new stage = registry entry + migration on both tables + threading at the call site. A new **model** = confirm the OpenRouter slug supports `structured_outputs` (plan/template stages) and `tools` (chat) → populate the RubyLLM registry in **every** environment → only then add the id to `AVAILABLE_MODELS`; that order is load-bearing, because RubyLLM owns its registry: it resolves against the `ruby_llm_models` table (its store) and falls back to the gem's bundled `models.json` only when that table is *empty*, so an id absent from both raises `ModelNotFoundError` on the four RubyLLM-backed stages. Check with `bin/verify-model-registry [candidate-id]`, fix with `RubyLLM.models.refresh!` plus a process restart (the registry is memoized per process); the refresh only ever adds or updates rows — per-provider fetch failures are logged and skipped, never allowed to empty the store. Full procedure in `docs/05-runbooks/03-llm-model-registry.md`. Selection applies on the OpenRouter path only — `roast_model_env` keeps claudesubscription runs on the operator's ENV/alias defaults, and an explicit `HIFUMI_DEV_MODEL`/`HIFUMI_DEV_DOCS_MODEL` always wins.
- **Agent sandbox (tenant isolation)**: the codegen agent runs `claude` with `skip_permissions!` on user-controlled prompts — treat it as untrusted code execution, so in production it must NOT run in the shared generator container. `ExecuteInstructionJob#execute_revision` wraps the roast invocation via `Roast::Sandbox.wrap` (`lib/roast/sandbox.rb`, plain builder, returns the `docker run` argv) into a throwaway `--rm` container that mounts ONLY this project's workspace (no `workspace_root`, no `/var/run/docker.sock`), runs entirely as the unprivileged `generator` user (`--user`, `--cap-drop=ALL`, zero cap-adds — uniform uid avoids the capless-root/mixed-ownership deadlock of issue #24; the workspace is re-relaxed `a+rwX` before every sandboxed run), and forwards env (incl. `OPENROUTER_API_KEY` and the per-project `HIFUMI_DEV_*` model selection) **by name** so secrets never hit argv. Gated by `sandboxed?` (production OR `FORCE_AGENT_SANDBOX=1`); dev stays direct (single-tenant, Claude-subscription transport, no Docker). Image = the generator's own, from `HIFUMI_AGENT_IMAGE` (set in `deploy.yml`). Not runtime-verifiable on macOS — see the verification checklist + residuals (generator-side socket, egress, bundle vendoring) in `docs/09-ideas/05-followups.md`.
- **Preview infrastructure**: `lib/preview/preview_manager.rb` (plain Ruby, not a Roast workflow) drives Docker. `lib/preview/Dockerfile{,.base}` are owned by this repo — never read from generated apps. `lib/preview/skeleton/` is the canonical fresh-Rails-app baseline copied into every workspace; regenerate with `bin/preview-regen-skeleton` when bumping Rails. Rebuild the base image with `bin/preview-rebuild-base` after Gemfile changes. The `preview-internal` Docker network is created without `--internal` on Docker Desktop (host port mapping wouldn't work otherwise) — Phase 4 reintroduces strict egress isolation on a Linux production host. In remote mode `PreviewManager#run_container` passes `PREVIEW_HOST=.preview.` to the container; the skeleton-overlay's `preview_iframe.rb` initializer appends it to `Rails.application.config.hosts` so Rails 8's dev HostAuthorization doesn't 403 the kamal-proxy request.
-- **RubyLLM tools must be idempotent within a user turn**. RubyLLM's tool loop will sometimes call the same tool twice in adjacent assistant messages before either result lands, producing the order `assistant(use_X) → assistant(use_Y) → user(result_X) → user(result_Y)` — illegal for Anthropic, and the chat permanently rejects every subsequent message. **There is no tool-side guard any more** — the tool that carried one was deleted in `13e9c1c`. What stands today is a prompt rule (*"Call each tool AT MOST ONCE per user turn"*, `app/prompts/generator_agent/instructions.txt.erb:18`) plus the `RubyLLM::BadRequestError` banner in `ChatRespondJob::FRIENDLY_ERRORS` that turns an already-corrupt chat into *"This conversation can't be continued by the model. Please start a new project."* So a new tool needs its own in-band guard: return an error hash rather than raising, so the second `tool_use` still gets a `tool_result` and history stays valid. Diagnose corrupt chats with `bin/inspect-chat ` (works on prod via `kamal app exec`).
+- **RubyLLM tools must be idempotent within a user turn**. RubyLLM's tool loop will sometimes call the same tool twice in adjacent assistant messages before either result lands, producing the order `assistant(use_X) → assistant(use_Y) → user(result_X) → user(result_Y)` — illegal for Anthropic, and the chat permanently rejects every subsequent message. **There is no tool-side guard any more** — the tool that carried one was deleted in `13e9c1c`. What stands today is a prompt rule (*"Call each tool AT MOST ONCE per user turn"*, `app/prompts/generator_agent/instructions.txt.erb:19`) plus the `RubyLLM::BadRequestError` banner in `ChatRespondJob::FRIENDLY_ERRORS` that turns an already-corrupt chat into *"This conversation can't be continued by the model. Please start a new project."* So a new tool needs its own in-band guard: return an error hash rather than raising, so the second `tool_use` still gets a `tool_result` and history stays valid. Diagnose corrupt chats with `bin/inspect-chat ` (works on prod via `kamal app exec`).
diff --git a/docs/09-ideas/05-followups.md b/docs/09-ideas/05-followups.md
index d9a63e6..dd632ac 100644
--- a/docs/09-ideas/05-followups.md
+++ b/docs/09-ideas/05-followups.md
@@ -161,6 +161,31 @@ The codegen agent now runs each revision in a per-instruction throwaway containe
---
+## 2026-06-17
+
+### Move untrusted previews to a separate registrable domain
+
+**Motivation**: previews (untrusted user/LLM code) are served as subdomains of the generator's own apex (`.preview.hifumi.dev`), so they share the registrable domain `hifumi.dev` with the trusted control plane and are therefore **same-site** with it. The cookie/header hardening shipped in PR #32 (`__Host-` session cookie, `force_ssl`/`assume_ssl` Secure cookies, host-only cookies, `Origin-Agent-Cluster`, CSRF) is a **mitigation, not a cure** — the boundary is structurally weak. The cure is serving previews from a **separate registrable domain**, which makes them cross-site to the generator. Full threat model + the single-vs-multiple-domain reasoning: `docs/02-architecture/05-tenant-isolation-and-domains.md`.
+
+**Why it matters**: a malicious generated app can set a `Domain=hifumi.dev` cookie that shadows the generator's session cookie (login CSRF / session fixation), `SameSite` offers no protection across the shared registrable domain, and `document.domain` can relax SOP to the parent. Each is currently patched, but any future feature that trusts "same-site" re-opens the class.
+
+**The migration is mostly config, not code** — the preview hostname is already parameterized on `PREVIEW_DOMAIN`:
+- `Preview::Config.domain` ← `ENV["PREVIEW_DOMAIN"]` (`config/initializers/preview_config.rb`)
+- `public_preview_host` = `.preview.#{domain}` (`lib/preview/preview_manager.rb`) → drives the kamal-proxy `--host`, the container's `PREVIEW_HOST` env, and `project.preview_url` (the studio iframe `src`)
+- CSP `frame-src https://*.preview.#{PREVIEW_DOMAIN}` (`config/initializers/content_security_policy.rb`) follows it
+
+So flipping the preview apex is: register a separate registrable domain (pick a `.dev` to keep auto-HSTS); add wildcard A `*.preview.` → host IP; issue a wildcard cert for it (same flow as `docs/05-runbooks/02-preview-wildcard-tls.md`, DNS-01 on the new domain's provider; repoint `PREVIEW_TLS_*` at it); set `PREVIEW_DOMAIN=` in `config/deploy.yml`. The generator stays on `hifumi.dev`; previews become `.preview.`, now cross-site to the control plane.
+
+**Decisions / caveats**:
+- One separate domain isolates **generator ↔ preview** (the high-value win). Previews still share the new apex, so **preview ↔ preview** cookie-tossing remains — acceptable for throwaway demos; full per-preview isolation needs unique domains (don't, unless previews hold secrets from each other).
+- Existing `.preview.hifumi.dev` links break, but previews are ephemeral (reaped ~30 min) — nothing durable to migrate.
+- Keep the PR #32 apex hardening as defense-in-depth after the move.
+- Optional cleanup: the `.preview.` sublabel is hardcoded in `public_preview_host` / `PREVIEW_HOST` / the CSP; if the new domain is preview-dedicated you could drop it (small code change, not required).
+
+**Cost**: a domain registration + the wildcard-cert setup you've now done once; the code change is ~one env var. The expense is operational (DNS + cert on a new domain), not engineering.
+
+---
+
## 2026-08-28
### RubyLLM v2 migration leftovers — both need their own migration
@@ -200,25 +225,8 @@ neither was in scope for the upgrade itself.
periodic orphan sweep or a partial constraint. Worth deciding before writing
any code that deletes messages outside AR callbacks.
-## 2026-06-17
-
-### Move untrusted previews to a separate registrable domain
-
-**Motivation**: previews (untrusted user/LLM code) are served as subdomains of the generator's own apex (`.preview.hifumi.dev`), so they share the registrable domain `hifumi.dev` with the trusted control plane and are therefore **same-site** with it. The cookie/header hardening shipped in PR #32 (`__Host-` session cookie, `force_ssl`/`assume_ssl` Secure cookies, host-only cookies, `Origin-Agent-Cluster`, CSRF) is a **mitigation, not a cure** — the boundary is structurally weak. The cure is serving previews from a **separate registrable domain**, which makes them cross-site to the generator. Full threat model + the single-vs-multiple-domain reasoning: `docs/02-architecture/05-tenant-isolation-and-domains.md`.
-
-**Why it matters**: a malicious generated app can set a `Domain=hifumi.dev` cookie that shadows the generator's session cookie (login CSRF / session fixation), `SameSite` offers no protection across the shared registrable domain, and `document.domain` can relax SOP to the parent. Each is currently patched, but any future feature that trusts "same-site" re-opens the class.
-
-**The migration is mostly config, not code** — the preview hostname is already parameterized on `PREVIEW_DOMAIN`:
-- `Preview::Config.domain` ← `ENV["PREVIEW_DOMAIN"]` (`config/initializers/preview_config.rb`)
-- `public_preview_host` = `.preview.#{domain}` (`lib/preview/preview_manager.rb`) → drives the kamal-proxy `--host`, the container's `PREVIEW_HOST` env, and `project.preview_url` (the studio iframe `src`)
-- CSP `frame-src https://*.preview.#{PREVIEW_DOMAIN}` (`config/initializers/content_security_policy.rb`) follows it
-
-So flipping the preview apex is: register a separate registrable domain (pick a `.dev` to keep auto-HSTS); add wildcard A `*.preview.` → host IP; issue a wildcard cert for it (same flow as `docs/05-runbooks/02-preview-wildcard-tls.md`, DNS-01 on the new domain's provider; repoint `PREVIEW_TLS_*` at it); set `PREVIEW_DOMAIN=` in `config/deploy.yml`. The generator stays on `hifumi.dev`; previews become `.preview.`, now cross-site to the control plane.
-
-**Decisions / caveats**:
-- One separate domain isolates **generator ↔ preview** (the high-value win). Previews still share the new apex, so **preview ↔ preview** cookie-tossing remains — acceptable for throwaway demos; full per-preview isolation needs unique domains (don't, unless previews hold secrets from each other).
-- Existing `.preview.hifumi.dev` links break, but previews are ephemeral (reaped ~30 min) — nothing durable to migrate.
-- Keep the PR #32 apex hardening as defense-in-depth after the move.
-- Optional cleanup: the `.preview.` sublabel is hardcoded in `public_preview_host` / `PREVIEW_HOST` / the CSP; if the new domain is preview-dedicated you could drop it (small code change, not required).
-
-**Cost**: a domain registration + the wildcard-cert setup you've now done once; the code change is ~one env var. The expense is operational (DNS + cert on a new domain), not engineering.
+**`messages.content_raw` is a third migration residual.** The upgrade adds
+v2's `raw_content` and leaves v1's `content_raw` in place. Nothing in the gem,
+`app/`, `lib/` or `bin/` reads it, so it silently holds every pre-upgrade raw
+payload under a name no v2 code path reaches. Same shape as the two above:
+needs its own migration, since the upgrade migration has already run.
diff --git a/test/models/message_test.rb b/test/models/message_test.rb
index 6ea14a7..7afa372 100644
--- a/test/models/message_test.rb
+++ b/test/models/message_test.rb
@@ -49,6 +49,26 @@ class MessageTest < ActiveSupport::TestCase
assert msg.visible_in_chat?
end
+ # The tool_call? disjunct of visible_in_chat?. The one other test that puts a
+ # tool call on a message gives it prose too, so content.present? short-circuits
+ # and this branch is never reached there. If it regressed, message_row_class
+ # would return "hidden" and every build-started pill would vanish silently.
+ test "an assistant message with no prose is visible_in_chat when it carries a tool call" do
+ msg = @chat.messages.create!(role: :assistant, content: "")
+ msg.ruby_llm_tool_calls.create!(
+ tool_call_id: "tc_visible", name: "create_application",
+ arguments: { "intent" => "habit tracker" }
+ )
+
+ assert msg.visible_in_chat?
+ end
+
+ test "an assistant message with neither prose nor a tool call is not visible_in_chat" do
+ msg = @chat.messages.create!(role: :assistant, content: "")
+
+ refute msg.visible_in_chat?
+ end
+
test "system_injected messages do not enqueue an append broadcast" do
assert_no_enqueued_jobs(only: Turbo::Streams::ActionBroadcastJob) do
@chat.messages.create!(role: :user, content: "hi", system_injected: true)
diff --git a/test/schemas/plan_schema_test.rb b/test/schemas/plan_schema_test.rb
index f0f144a..6171083 100644
--- a/test/schemas/plan_schema_test.rb
+++ b/test/schemas/plan_schema_test.rb
@@ -1,5 +1,13 @@
require "test_helper"
+# These assertions pin the schema's SHAPE. They do NOT guard the
+# `require "schematist"` in app/schemas/plan_schema.rb: under parallelize +
+# random order, any test touching CreateApplication loads RubyLLM::Tool, which
+# requires schematist for the rest of the process -- so this file resolves
+# PlanSchema either way. What actually guards that require is
+# `config.eager_load = ENV["CI"].present?` in config/environments/test.rb: on CI
+# the app eager-loads and `class PlanSchema < Schematist::Schema` fails at
+# definition time without it. Locally (eager_load off) its removal passes.
class PlanSchemaTest < ActiveSupport::TestCase
# The only test that resolves PlanSchema at all. Both AdHocLLM suites stub
# invoke_llm above the schema, so without this a missing `require