L1: the integrity check reads the scroll (tablet 38)

L1Reader.verify_integrity/0 read the hash stored in integrity.sha256
and compared that string with the hash compiled into the module. It
never opened a verse. On that comparison of a constant with its own
copy rested `l1_hash: pass`, the Cloud's "L1 corrupted — the cloud is
not visible", and Construction §1.2. A changed verse would have passed
every one of them. Every existing test passed too, because every test
asked about an untouched L1.

- L1Reader.compute_tree_hash/1: SHA-256 over every file under torah/ —
  relative path, then bytes — in the order scripts/build_l1.py hashed
  them (directories by path, files by name, plain string order). On the
  real L1 it yields the pinned 156b1d27…; for a tampered copy it yields
  the same hash the Python implementation does.
- VavPipeline.L1Integrity (new, supervised): reads and hashes the scroll
  when the pipeline starts (~0.9 s in the image) and again every ten
  minutes; logs and signals the assembly when the answer changes.
- verify_integrity/0 now requires BOTH: the recorded hash is the pinned
  one, AND the scroll on disk hashes to it (answered from the witness's
  last reading; computed on the spot where the witness is not running).
  Callers are unchanged — the Shema context, the health check and the
  status endpoint all inherit the real check.
- scripts/verify_l1.py: the same reading by hand. KOHEN.md and
  KOHEN_PROMPT.md used the hollow comparison as their L1 gate; they now
  run this.
- test/tablet38_l1_integrity_test.exs — 9 tests: the hash and its
  order; one byte / one added file / one rename each change it; the
  witness; and the whole path — with an altered scroll and an untouched
  integrity.sha256, verify_integrity fails, health says l1_hash: fail,
  and /peah/ask does not answer.

Specs: architecture §2.2 (which had been made to confess the hollow
check in d7520cd) and Construction §1.2 now describe the real one — and
its limit: a change is noticed within ten minutes, not at the instant.
deploy/ARCHITECTURE.md likewise. Doorpost @expected_hash 7e5286b9… →
3f6d47a0…, in this commit, with the witnessed files that moved it
(architecture, vayakhel).

Read before committing: Elixir 483/0 on seeds 0 7 42 99 191243. The
built image, run twice: on the engraved scroll — "read and matches
(928ms)", l1_hash pass, /peah/ask 200; on a copy with ONE LETTER changed
in Bereishit 1:1 and integrity.sha256 untouched — L1_INTEGRITY_FAILED,
l1_hash fail, cloud not readable, /peah/ask 503.

Not done: the Haskell side. Luchot.Firmware.l1ExpectedHash is compared
with nothing; the engine reads L1 without hashing it (that would need a
SHA-256 dependency). The engine is reached only through the pipeline,
which now gates every cycle on the real check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Scott KarpandClaude Fable 5.1 committed 2026-10-04 19:37:40 +00:00
1 parent d7520cd69d
commit bbe2c4b7ba
11 files changed
+579 -17

No files matched your search

@@ -33,6 +33,9 @@ defmodule VavPipeline.Application do
children =
[
{VavPipeline.Chatzotzrot, []},
# Tablet 38 — reads the scroll and hashes it; everything that asks
# "is L1 intact?" is answered from this reading.
{VavPipeline.L1Integrity, []},
{VavPipeline.AreiMiklat, []},
{VavPipeline.EglahArufah, []},
{VavPipeline.Bikurim, []},
+1 -1
View File
@@ -175,7 +175,7 @@ defmodule VavPipeline.Doorpost do
# changes; the update is a deliberate operator commit. A bare
# hash-update commit (without a corresponding spec change in the
# same commit) is itself a drift signal.
@expected_hash "7e5286b99aaefa49c7516d3e27dae9d7e3f4fc9ae89347c92c5394685d953400"
@expected_hash "3f6d47a0b91ce12a1002cfe2d0a6bfe7af4fb280cd05264c87ba914d258a06fe"
@doc "Return the list of files that constitute the category-grade specification."
@spec files() :: [String.t()]
@@ -0,0 +1,155 @@
defmodule VavPipeline.L1Integrity do
@moduledoc """
The witness that the scroll in this vessel is the scroll that was engraved.
Broken tablet 38 (2026-10-04). `L1Reader.verify_integrity/0` read the
hash stored in `integrity.sha256` and compared that string with the hash
compiled into this module's neighbour. It never opened a verse. Two
copies of one constant were compared with each other, and on that
comparison rested `l1_hash: pass`, the Cloud's "L1 corrupted — the cloud
is not visible", and Construction §1.2's "if the hash does not match, the
system does not start." A verse could have been changed and every one of
them would have gone on saying *tov*.
This process reads the scroll. When it starts it hashes the whole of
`torah/` — every path and every byte, in the order `scripts/build_l1.py`
hashed them when L1 was engraved — and it does so again at an interval
(ten minutes unless configured). `L1Reader.verify_integrity/0` asks this
process what it found; the answer is a comparison of what is on disk
with what was pinned, not of the pin with itself.
Hashing 5,859 files takes a fraction of a second, which is too long to
do before every cycle and every health probe; so the cycle is answered
from the last reading, and the reading is never older than the interval.
Callers that arrive while the first reading is under way wait for it.
*Lo tosif alav v'lo tigra mimenu* — you shall not add to it and you shall
not take from it (Devarim 13:1). The check is how the vessel knows.
"""
use GenServer
require Logger
alias VavPipeline.L1Reader
@default_interval :timer.minutes(10)
@type reading :: %{
status: :ok | {:error, String.t()},
computed: String.t() | nil,
expected: String.t(),
read_at: DateTime.t()
}
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@doc "`:ok` if the scroll on disk hashes to the pinned hash, else `{:error, reason}`."
@spec status(GenServer.server()) :: :ok | {:error, String.t()}
def status(server \\ __MODULE__), do: GenServer.call(server, :status, 60_000)
@doc "The last reading in full — what was computed, what was expected, and when."
@spec reading(GenServer.server()) :: reading()
def reading(server \\ __MODULE__), do: GenServer.call(server, :reading, 60_000)
@doc "Read the scroll again now, and return what was found."
@spec recheck(GenServer.server()) :: :ok | {:error, String.t()}
def recheck(server \\ __MODULE__), do: GenServer.call(server, :recheck, 60_000)
# ------------------------------------------------------------
# GenServer
# ------------------------------------------------------------
@impl true
def init(opts) do
state = %{
root: Keyword.get(opts, :l1_root, L1Reader.l1_root()),
expected: Keyword.get(opts, :expected_hash, L1Reader.expected_hash()),
interval:
Keyword.get(
opts,
:interval,
Application.get_env(:vav_pipeline, :l1_rehash_interval_ms, @default_interval)
),
reading: nil
}
{:ok, state, {:continue, :read}}
end
@impl true
def handle_continue(:read, state) do
state = read(state)
schedule(state)
{:noreply, state}
end
@impl true
def handle_info(:read, state) do
state = read(state)
schedule(state)
{:noreply, state}
end
@impl true
def handle_call(:status, _from, state), do: {:reply, state.reading.status, state}
def handle_call(:reading, _from, state), do: {:reply, state.reading, state}
def handle_call(:recheck, _from, state) do
state = read(state)
{:reply, state.reading.status, state}
end
defp schedule(%{interval: :never}), do: :ok
defp schedule(%{interval: ms}), do: Process.send_after(self(), :read, ms)
defp read(state) do
{micros, result} = :timer.tc(fn -> L1Reader.compute_tree_hash(state.root) end)
previous = state.reading && state.reading.status
{status, computed} =
case result do
{:ok, hash} when hash == state.expected ->
{:ok, hash}
{:ok, hash} ->
{{:error,
"L1_INTEGRITY_FAILED: the scroll on disk hashes to #{hash}, " <>
"expected #{state.expected}"}, hash}
{:error, reason} ->
{{:error, "L1_INTEGRITY_FAILED: the scroll could not be read — #{reason}"}, nil}
end
announce(status, previous, div(micros, 1000))
%{
state
| reading: %{
status: status,
computed: computed,
expected: state.expected,
read_at: DateTime.utc_now()
}
}
end
# Say it when it is first known and whenever it changes; stay quiet on
# the ten-minute readings that find what the last one found.
defp announce(status, status, _ms), do: :ok
defp announce(:ok, _previous, ms) do
Logger.info("L1Integrity: the scroll was read and matches its pinned hash (#{ms}ms)")
end
defp announce({:error, reason}, _previous, _ms) do
Logger.error("L1Integrity: #{reason}")
if Process.whereis(VavPipeline.Chatzotzrot) do
VavPipeline.Chatzotzrot.signal(:assembly_leaders, reason: reason)
end
:ok
end
end
+92 -2
View File
@@ -244,10 +244,22 @@ defmodule VavPipeline.L1Reader do
end
@doc """
Verify L1 integrity by checking the SHA-256 hash.
Verify L1 integrity: the scroll on disk must hash to the pinned hash.
This is a critical check — if L1 is corrupted, the pipeline
must halt. The specification store is inviolable.
Two things are asked, and both must hold:
1. The hash recorded beside the scroll (`integrity.sha256`) is the
pinned one — this L1 is the L1 this build was made for.
2. The scroll itself, read from disk and hashed, gives that hash
(`VavPipeline.L1Integrity`, which reads it at startup and again
every ten minutes).
Until tablet 38 only the first was asked, and the first compares a
recorded string with a compiled-in string: it could not notice a
changed verse.
"""
@spec verify_integrity() :: :ok | {:error, String.t()}
def verify_integrity do
@@ -258,7 +270,7 @@ defmodule VavPipeline.L1Reader do
stored_hash = contents |> String.trim()
if stored_hash == @l1_expected_hash do
:ok
verify_scroll()
else
{:error, "L1_INTEGRITY_FAILED: hash mismatch — stored #{stored_hash}, expected #{@l1_expected_hash}"}
end
@@ -268,6 +280,84 @@ defmodule VavPipeline.L1Reader do
end
end
# What the scroll on disk actually hashes to. The supervised witness
# holds a recent reading; where it is not running (a bare unit test,
# a one-off `eval`), the scroll is read here and now.
defp verify_scroll do
if Process.whereis(VavPipeline.L1Integrity) do
VavPipeline.L1Integrity.status()
else
case compute_tree_hash() do
{:ok, @l1_expected_hash} ->
:ok
{:ok, other} ->
{:error,
"L1_INTEGRITY_FAILED: the scroll on disk hashes to #{other}, expected #{@l1_expected_hash}"}
{:error, reason} ->
{:error, "L1_INTEGRITY_FAILED: the scroll could not be read — #{reason}"}
end
end
end
@doc """
Hash the scroll: SHA-256 over every file under `<root>/torah`, each
contributing its path relative to `torah/` and then its bytes.
The order is the order `scripts/build_l1.py` (`generate_integrity_hash`)
used when L1 was engraved, and must stay so or the two will disagree:
directories in ascending order of their path, and within a directory the
files in ascending order of name — plain string order, so `10.json`
comes before `2.json`.
"""
@spec compute_tree_hash(String.t()) :: {:ok, String.t()} | {:error, String.t()}
def compute_tree_hash(root \\ l1_root()) do
torah = Path.join(root, "torah")
if File.dir?(torah) do
digest =
[torah | subdirectories(torah)]
|> Enum.sort()
|> Enum.reduce(:crypto.hash_init(:sha256), fn dir, ctx ->
dir
|> File.ls!()
|> Enum.sort()
|> Enum.reduce(ctx, fn name, ctx ->
path = Path.join(dir, name)
if File.regular?(path) do
ctx
|> :crypto.hash_update(Path.relative_to(path, torah))
|> :crypto.hash_update(File.read!(path))
else
ctx
end
end)
end)
|> :crypto.hash_final()
|> Base.encode16(case: :lower)
{:ok, digest}
else
{:error, "#{torah} is not a directory"}
end
rescue
e in [File.Error] -> {:error, Exception.message(e)}
end
defp subdirectories(dir) do
dir
|> File.ls!()
|> Enum.map(&Path.join(dir, &1))
|> Enum.filter(&File.dir?/1)
|> Enum.flat_map(fn sub -> [sub | subdirectories(sub)] end)
end
@doc "The pinned L1 hash this build expects."
@spec expected_hash() :: String.t()
def expected_hash, do: @l1_expected_hash
@doc """
Get the root path of L1.
"""