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>
269 lines
9.6 KiB
Elixir
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
|