Files
bikoret/vav_pipeline/test/tablet38_l1_integrity_test.exs
T
Scott KarpandClaude Fable 5.1 ec8cdc840b L1Integrity: the ten-minute re-reading runs beside the witness, not in it
On the production node a reading of the scroll takes about three
seconds (3310 ms at first start; 0.9 s on the build host). The periodic
re-reading ran inside the GenServer, so once every ten minutes every
cycle and every readiness probe that asked "is L1 intact?" would have
waited those seconds behind it.

The periodic reading is now done by a task that reports back; the
process goes on answering from its last reading meanwhile. The first
reading is still made in the process itself — nobody is answered before
the scroll has been read once — and an explicit recheck/0 is still
synchronous. The next reading is scheduled when one begins, not when it
reports, so a reading that dies silently is not the last.

Two tests: it re-reads on its own in both directions (altered → fail,
restored → tov), and a status call during a periodic reading returns at
once. 484 tests, 0 failures on seeds 0 7 42 99.

Built image, run with a live-mounted scroll: l1_hash pass, /peah/ask
200; then אחד in Devarim 6:4 was changed to אחר while the vessel ran
and the witness asked to read again — L1_INTEGRITY_FAILED, l1_hash
fail, /peah/ask 503.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 19:42:37 +00:00

269 lines
9.6 KiB
Elixir

defmodule VavPipeline.Tablet38L1IntegrityTest do
@moduledoc """
Tablet 38 (2026-10-04) — the integrity check did not check integrity.
`L1Reader.verify_integrity/0` compared the hash recorded in
`integrity.sha256` with the hash compiled into the module. It never read
a verse. Every test of it passed, because every test asked it about an
untouched L1 — the one question a check that checks nothing answers
correctly.
These tests change the scroll and ask again.
"""
use ExUnit.Case, async: false
import Plug.Test
import Plug.Conn
alias VavPipeline.{HaltMonitor, L1Integrity, L1Reader, MannaLedger}
alias VavPipeline.BigdeiKodesh.Router
@opts Router.init([])
# ============================================================
# The hash itself
# ============================================================
describe "compute_tree_hash/1" do
test "of the real scroll is the pinned hash, and the hash build_l1.py recorded" do
assert {:ok, hash} = L1Reader.compute_tree_hash()
assert hash == L1Reader.expected_hash()
recorded = L1Reader.l1_root() |> Path.join("integrity.sha256") |> File.read!() |> String.trim()
assert hash == recorded
end
test "hashes path then bytes, directories then files, in plain string order" do
root = small_scroll()
# The order build_l1.py walks: the torah root's own files, then each
# directory by path; within a directory, names as strings — so
# 10.json comes before 2.json.
expected =
[
{"index.json", "INDEX"},
{"alef/1/1.json", "one"},
{"alef/1/10.json", "ten"},
{"alef/1/2.json", "two"},
{"alef/2/1.json", "second chapter"}
]
|> Enum.reduce(:crypto.hash_init(:sha256), fn {rel, bytes}, ctx ->
ctx |> :crypto.hash_update(rel) |> :crypto.hash_update(bytes)
end)
|> :crypto.hash_final()
|> Base.encode16(case: :lower)
assert {:ok, ^expected} = L1Reader.compute_tree_hash(root)
end
test "one changed byte, one added file, one renamed file — each changes the hash" do
root = small_scroll()
{:ok, original} = L1Reader.compute_tree_hash(root)
verse = Path.join([root, "torah", "alef", "1", "2.json"])
File.write!(verse, "twO")
assert {:ok, changed} = L1Reader.compute_tree_hash(root)
assert changed != original
File.write!(verse, "two")
assert {:ok, ^original} = L1Reader.compute_tree_hash(root)
File.write!(Path.join([root, "torah", "alef", "1", "3.json"]), "an added verse")
assert {:ok, added} = L1Reader.compute_tree_hash(root)
assert added != original
File.rm!(Path.join([root, "torah", "alef", "1", "3.json"]))
File.rename!(verse, Path.join([root, "torah", "alef", "1", "20.json"]))
assert {:ok, renamed} = L1Reader.compute_tree_hash(root)
assert renamed != original
end
test "a scroll that is not there is an error, not a hash" do
assert {:error, reason} = L1Reader.compute_tree_hash("/nonexistent/l1")
assert reason =~ "not a directory"
end
end
# ============================================================
# The witness
# ============================================================
describe "L1Integrity" do
test "reads the scroll when it starts, and again when asked" do
root = small_scroll()
{:ok, pinned} = L1Reader.compute_tree_hash(root)
{:ok, pid} =
L1Integrity.start_link(name: :tablet38_witness, l1_root: root, expected_hash: pinned, interval: :never)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
assert :ok = L1Integrity.status(:tablet38_witness)
assert %{computed: ^pinned, expected: ^pinned} = L1Integrity.reading(:tablet38_witness)
# Someone alters a verse. The last reading still says tov — it is a
# reading, not a guard — until the scroll is read again.
File.write!(Path.join([root, "torah", "alef", "1", "1.json"]), "0ne")
assert {:error, reason} = L1Integrity.recheck(:tablet38_witness)
assert reason =~ "L1_INTEGRITY_FAILED"
assert reason =~ "expected #{pinned}"
assert {:error, _} = L1Integrity.status(:tablet38_witness)
# Restored, it is tov again.
File.write!(Path.join([root, "torah", "alef", "1", "1.json"]), "one")
assert :ok = L1Integrity.recheck(:tablet38_witness)
end
test "re-reads on its own at the interval" do
root = small_scroll()
{:ok, pinned} = L1Reader.compute_tree_hash(root)
{:ok, pid} =
L1Integrity.start_link(name: :tablet38_periodic, l1_root: root, expected_hash: pinned, interval: 50)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
assert :ok = L1Integrity.status(:tablet38_periodic)
File.write!(Path.join([root, "torah", "index.json"]), "INDEX, altered")
assert eventually(fn -> match?({:error, _}, L1Integrity.status(:tablet38_periodic)) end)
# And it keeps re-reading: restored, it is tov again without being asked.
File.write!(Path.join([root, "torah", "index.json"]), "INDEX")
assert eventually(fn -> L1Integrity.status(:tablet38_periodic) == :ok end)
end
test "answers from the last reading while a periodic re-reading is under way" do
root = small_scroll()
{:ok, pinned} = L1Reader.compute_tree_hash(root)
{:ok, pid} =
L1Integrity.start_link(name: :tablet38_busy, l1_root: root, expected_hash: pinned, interval: :never)
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
# Trigger a periodic reading by hand; the process must not be busy
# with it — a status call is answered at once, from the last reading.
send(pid, :read)
{micros, status} = :timer.tc(fn -> L1Integrity.status(:tablet38_busy) end)
assert status == :ok
assert micros < 50_000
end
end
# ============================================================
# The whole path: an altered scroll stops the vessel
# ============================================================
describe "with a scroll that does not match its pin" do
setup do
if HaltMonitor.halted?(), do: HaltMonitor.resolve("tablet38 test setup")
MannaLedger.reset()
write_provision(20)
# Sanity: with the real scroll, all is well.
assert :ok = L1Reader.verify_integrity()
# Replace the supervised witness with one that reads an altered
# copy — the recorded hash file is untouched and still "matches",
# which is exactly the case the old check could not see.
root = small_scroll()
:ok = Supervisor.terminate_child(VavPipeline.Supervisor, L1Integrity)
{:ok, pid} =
L1Integrity.start_link(l1_root: root, expected_hash: L1Reader.expected_hash(), interval: :never)
on_exit(fn ->
if Process.alive?(pid), do: GenServer.stop(pid)
{:ok, _} = Supervisor.restart_child(VavPipeline.Supervisor, L1Integrity)
MannaLedger.reset()
File.rm(VavPipeline.Manna.provision_file())
end)
:ok
end
test "verify_integrity fails, though integrity.sha256 still holds the pinned hash" do
recorded = L1Reader.l1_root() |> Path.join("integrity.sha256") |> File.read!() |> String.trim()
assert recorded == L1Reader.expected_hash()
assert {:error, reason} = L1Reader.verify_integrity()
assert reason =~ "L1_INTEGRITY_FAILED"
assert reason =~ "the scroll on disk hashes to"
end
test "health says l1_hash: fail and the vessel is not ready" do
conn = conn(:get, "/kodesh/health") |> Router.call(@opts)
body = Jason.decode!(conn.resp_body)
assert conn.status == 503
assert body["checks"]["l1_hash"] == "fail"
end
test "no cycle runs on it — /peah/ask does not answer from an altered scroll" do
conn =
conn(:post, "/peah/ask", Jason.encode!(%{question: "בראשית ברא אלהים"}))
|> put_req_header("content-type", "application/json")
|> Router.call(@opts)
refute conn.status == 200
refute Map.has_key?(Jason.decode!(conn.resp_body), "answer")
end
end
# ------------------------------------------------------------
# Helpers
# ------------------------------------------------------------
defp small_scroll do
root = Path.join(System.tmp_dir!(), "tablet38_#{System.unique_integer([:positive])}")
on_exit(fn -> File.rm_rf!(root) end)
files = %{
"index.json" => "INDEX",
"alef/1/1.json" => "one",
"alef/1/2.json" => "two",
"alef/1/10.json" => "ten",
"alef/2/1.json" => "second chapter"
}
for {rel, bytes} <- files do
path = Path.join([root, "torah", rel])
File.mkdir_p!(Path.dirname(path))
File.write!(path, bytes)
end
root
end
defp eventually(fun, tries \\ 100) do
cond do
fun.() -> true
tries == 0 -> false
true -> Process.sleep(20) && eventually(fun, tries - 1)
end
end
defp write_provision(compute_units) do
today = Date.utc_today() |> Date.to_iso8601()
expires = DateTime.utc_now() |> DateTime.add(6 * 3600) |> DateTime.to_iso8601()
provision = %{
"date" => today,
"provision" => %{
"compute_units" => compute_units,
"memory_mb" => 512,
"api_calls_external" => 5,
"storage_delta_mb" => 2,
"sanhedrin_panels_max" => 2
},
"type" => "regular",
"expires_at" => expires,
"written_by" => "tablet38_test",
"written_at" => DateTime.utc_now() |> DateTime.to_iso8601()
}
path = VavPipeline.Manna.provision_file()
File.mkdir_p!(Path.dirname(path))
File.write!(path, Jason.encode!(provision))
end
end