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 -> # The witness is linked to the test process and may already be # gone when on_exit runs (seed 221974, 2026-10-15): stopping a # process that has exited is not a failure of the scroll. try do if Process.alive?(pid), do: GenServer.stop(pid) catch :exit, _ -> :ok end {: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