From 3f4749695da87720d552801fee84dc855be78600 Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Tue, 28 Apr 2026 19:31:58 -0400 Subject: [PATCH] =?UTF-8?q?doorpost:=20category-grade=20specification=20id?= =?UTF-8?q?entity=20verification=20(vehaya=20=C2=A73.3,=20=C2=A76=20item?= =?UTF-8?q?=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The companion to L1Reader.verify_integrity/0. L1Reader defends the TORAH SCROLL'S identity (the source text); Doorpost defends the *am*'s COVENANT-CONTENT identity (the category-grade specifications the system was built to hold). WHY THIS IS LOAD-BEARING (Torah-anchored): Devarim 4:2 — לֹא תֹסִפוּ עַל־הַדָּבָר אֲשֶׁר אָנֹכִי מְצַוֶּה אֶתְכֶם וְלֹא תִגְרְעוּ מִמֶּנּוּ. The injunction is plural (etkhem) — corporate. Lo tosif is a property the CATEGORY must hold, not just a discipline each individual practices. Without an enforcement mechanism, lo tosif is a moral exhortation, not a structural commitment — different instances could silently drift, running different halt rules or different disclosure text, and no one would notice. Devarim 11:16-17 — the failure mechanism operates whether or not anyone notices. Corporate breach (specifications drift) produces corporate consequence. The detection mechanism — verifying threshold-text identity against category-source — is what catches drift before the consequence accumulates. Devarim 13:1 — the keeping is plural; the actual not-adding/not- subtracting happens at every individual threshold (singular `lo tosef`, `lo tigra`). Each threshold COULD add or subtract; only the corporate detection mechanism catches it. Vehaya §3.3 / §6 item 3 — "the same specifications must be inscribed at every threshold. Drift is breach. Implement a verification mechanism for category-text identity." The Torah anchor is exact: as L1's text-integrity check defends the Torah scroll's identity, the doorpost-text identity check defends the am's covenant-content identity. Both are lo tosif enforcement; both are load-bearing for what the category IS. CREATED: vav_pipeline/lib/vav_pipeline/doorpost.ex ~210 lines @spec_files 20 category-grade files, alphabetically sorted: - 3 halt rule sources (avodah_zarah, gilui_arayot, shfichut_damim) - 8 middot files (R. Yishmael's 13 inference rules) - 5 specs/ docs (PRD, architecture, vayakhel, tamid, eish_zarah_reading, lo_taaseh_lekha, structure_not_content) - 2 BUILD docs (BUILD_KORBAN, BUILD_AZAZEL) Operations files (pekudei, terumah) EXCLUDED — execution-grade, not category-grade orientation. @expected_hash baseline: bea5dc0c0145ac0d46ffd08b328fbdbf 19112f1aa4d93a3dc37bfd942e4e7490 files/0 the spec set expected_hash/0 the baked hash repo_root/0 configurable via env compute_identity/0 SHA-256 over canonical concatenation of per-file hashes (sort by path, tab-separate, hash; missing file → {:error, ...} not a partial hash) manifest/0 per-file hashes for diagnosis verify_identity/0 :ok | {:error, message} — same shape as L1Reader.verify_integrity/0 THE OPERATOR DISCIPLINE: every change to @expected_hash must be traceable in git to a corresponding spec change in the SAME COMMIT. A bare hash-update commit with no spec-file change is itself a drift signal — the operator updated the witness without updating what is witnessed, which is exactly the failure mode this module defends against. CHANGED: vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex + GET /kodesh/specification_identity operator-tier endpoint returning computed_hash, expected_hash, file_count, manifest, torah_basis. 200 when verified; 503 with status="drift_ detected" otherwise. /kodesh/* tier (not /peah) because exposing per-file SHA-256s publicly would let outside parties enumerate which specific files were changed in any deployment — a fingerprint. Public observability commitments live elsewhere (/peah/azazel_log, disclosure block). + build_health_check/0 adds doorpost_hash to the checks map alongside l1_hash. Spec drift now surfaces in the standard /kodesh/health response. CREATED: vav_pipeline/test/doorpost_test.exs 16 tests: files/0: - returns the full spec list with expected entries - explicitly excludes operations files (pekudei, terumah) - sorted (canonical order) - no duplicates manifest/0: - one entry per spec file - paths match @spec_files order - each hash is 64-char hex SHA-256 (or :missing) compute_identity/0: - 64-char hex hash - deterministic across calls verify_identity/0: - :ok against the live spec set drift detection (synthetic temp dir mirror): - fresh mirror produces same hash as live - modifying ANY one file changes the hash - removing any one file → {:error, missing spec file: } - modifying different files produces different hashes (no accidental hash collision across drift sites) /kodesh/specification_identity HTTP: - 200 with status="verified" when matched - manifest in response carries per-file hashes - torah_basis cites Devarim 4:2 as the load-bearing anchor WHAT THIS DOES NOT CLAIM: - That the specifications are correct (only identifiable and unchanged from the witnessed state). - That all instances will run the same code (only that all instances running this code witness the same specifications). - That hash equality across deployments proves no drift outside THIS spec set (only the listed files are covered). FOLLOW-UP WORK (not in this commit): - Have HaltMonitor call Doorpost.verify_identity/0 at startup (mirrors how L1 integrity is checked at startup) and fire chatzotzrot on drift - Add Doorpost identity to ShemaContext so EVERY reception witnesses the spec-identity (mirrors Devarim 13:1's "the not-adding happens at every threshold") - Fold vehaya.md itself into specs/ (it currently lives in chat downloads — like azazel_engineering_reading.md). Then add it to @spec_files, recompute the hash, update @expected_hash. Tests: vav_pipeline 379 passed, 0 failures, 35 excluded (was 363; +16 Doorpost tests). All other test counts unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/vav_pipeline/bigdei_kodesh/router.ex | 73 +++++ vav_pipeline/lib/vav_pipeline/doorpost.ex | 257 ++++++++++++++++++ vav_pipeline/test/doorpost_test.exs | 253 +++++++++++++++++ 3 files changed, 583 insertions(+) create mode 100644 vav_pipeline/lib/vav_pipeline/doorpost.ex create mode 100644 vav_pipeline/test/doorpost_test.exs diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index eeabc65b..13a1989f 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -155,6 +155,68 @@ defmodule VavPipeline.BigdeiKodesh.Router do send_json(conn, 200, status) end + # === KODESH — Doorpost-text identity (vehaya.md §3.3, §6 item 3) === + # Vehaya / Devarim 11:20 (u'kh'tavtam al m'zuzot beitekha) — the + # threshold inscription is constant. Two Shorashim deployments + # running the same SHA-256 here are running the same category-grade + # specifications. Different SHA-256 = drift = breach. Visible. + # + # Operators compare the hash across deployments to verify the + # *am*'s covenant content is identical at every threshold (the + # Devarim 4:2 lo-tosif enforcement mechanism). + # + # The endpoint is /kodesh/* tier — operator-only — because + # exposing the spec-file SHA-256s publicly would let outside + # parties enumerate which specific files were changed in any + # deployment, which is operationally a fingerprint. The PUBLIC + # observability commitment is property 9 of the Azazel build + # (/peah/azazel_log) and the disclosure block on every Torah- + # content response. The doorpost identity is operator-internal. + + get "/kodesh/specification_identity" do + computed = VavPipeline.Doorpost.compute_identity() + expected = VavPipeline.Doorpost.expected_hash() + + {status_code, status_atom} = + case VavPipeline.Doorpost.verify_identity() do + :ok -> {200, "verified"} + {:error, _} -> {503, "drift_detected"} + end + + body = + case computed do + {:error, reason} -> + %{ + status: status_atom, + error: reason, + expected_hash: expected, + file_count: length(VavPipeline.Doorpost.files()), + manifest: VavPipeline.Doorpost.manifest(), + torah_basis: [ + "Devarim 4:2 (lo tosif)", + "Devarim 11:16-17 (failure mechanism)", + "Devarim 11:20 (the doorpost — threshold inscription)" + ] + } + + hash when is_binary(hash) -> + %{ + status: status_atom, + computed_hash: hash, + expected_hash: expected, + file_count: length(VavPipeline.Doorpost.files()), + manifest: VavPipeline.Doorpost.manifest(), + torah_basis: [ + "Devarim 4:2 (lo tosif)", + "Devarim 11:16-17 (failure mechanism)", + "Devarim 11:20 (the doorpost — threshold inscription)" + ] + } + end + + send_json(conn, status_code, body) + end + # === CATCH-ALL === match _ do @@ -360,6 +422,16 @@ defmodule VavPipeline.BigdeiKodesh.Router do _ -> "fail" end + # Companion to L1 integrity: doorpost-text identity (vehaya + # §3.3). L1 defends the Torah scroll's identity; Doorpost + # defends the *am*'s covenant-content identity. Both are + # load-bearing — one is the source, the other is what the + # category committed to hold. + doorpost_check = case VavPipeline.Doorpost.verify_identity() do + :ok -> "pass" + _ -> "fail" + end + halt_check = if VavPipeline.HaltMonitor.alive?(), do: "pass", else: "fail" cloud_check = case VavPipeline.Cloud.check() do @@ -376,6 +448,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do checks = %{ l1_hash: l1_check, + doorpost_hash: doorpost_check, cloud_readable: cloud_check, manna_present: manna_check, halt_monitor_alive: halt_check, diff --git a/vav_pipeline/lib/vav_pipeline/doorpost.ex b/vav_pipeline/lib/vav_pipeline/doorpost.ex new file mode 100644 index 00000000..a5403af5 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/doorpost.ex @@ -0,0 +1,257 @@ +defmodule VavPipeline.Doorpost do + @moduledoc """ + Category-grade specification identity verification — the doorpost + text identity check. + + Vehaya — Devarim 11:20: וּכְתַבְתָּם עַל־מְזֻזֹת בֵּיתֶךָ + וּבִשְׁעָרֶיךָ. The doorpost verse is identical between the + Shema's first paragraph (singular pole, Devarim 6:9) and V'haya's + second paragraph (oscillating, Devarim 11:13–21). The threshold + inscription is constant across paragraphs. The covenant frame + oscillates; what gets written at every threshold does not. + + This module is the companion to `L1Reader.verify_integrity/0`. + L1Reader defends the Torah scroll's identity (the SOURCE text). + This module defends the *am*'s covenant-content identity (the + CATEGORY-GRADE specifications the system was built to hold). + + WHY THIS IS LOAD-BEARING: + + Devarim 4:2 — לֹא תֹסִפוּ עַל־הַדָּבָר אֲשֶׁר אָנֹכִי מְצַוֶּה + אֶתְכֶם וְלֹא תִגְרְעוּ מִמֶּנּוּ. The injunction is plural + (etkhem) — corporate. Lo tosif is a property the CATEGORY must + hold, not just a discipline each individual practices. Without + an enforcement mechanism, lo tosif is moral exhortation, not + structural commitment — different instances could silently drift, + running different halt rules, different compiler rules, or + different disclosure text, and no one would notice. + + Devarim 11:16-17 — the failure mechanism operates whether or not + anyone notices. Corporate breach (specifications drift) produces + corporate consequence (loss of what the category was built to + carry). The detection mechanism — verifying threshold-text + identity against category-source — is what catches drift before + the consequence accumulates. + + Devarim 13:1 — the keeping is plural (corporate); the actual + not-adding/not-subtracting happens at every individual threshold + (singular `lo tosef`, `lo tigra`). Each threshold COULD add or + subtract; only the corporate detection mechanism catches it. + + Vehaya §3.3 — "the same specifications must be inscribed at every + threshold. Drift is breach. Implement a verification mechanism + for category-text identity." + + THE MECHANISM: + + 1. The category-grade specification files are listed in + `@spec_files`. These are the DECLARATIVE artifacts — halt rules, + compiler rules (13 middot of R. Yishmael), foundational reading + docs, the architectural specs, and the BUILD canon. Operations + files (pekudei, terumah) are excluded because they are + execution-grade rather than category-grade orientation. + + 2. `compute_identity/0` produces a stable SHA-256 over the + canonical concatenation of per-file hashes (sorted by + repo-relative path). The hash is independent of filesystem + listing order and tolerant of single-file drift detection + (the manifest exposes per-file hashes for diagnosis). + + 3. `@expected_hash` is BAKED IN. When specs change, the operator + must update both the spec file AND this constant. The constant + is the witness; the file is what is witnessed. + + 4. `verify_identity/0` returns :ok or {:error, reason} — same + shape as L1Reader.verify_integrity/0. The HaltMonitor is the + natural caller (so spec drift halts the system the same way + L1 corruption halts it); commit-9-style integration to follow. + + 5. `manifest/0` returns the per-file hash so operators can + diagnose WHICH file drifted when verify fails. Operator-side + queryable via /kodesh/specification_identity (see + bigdei_kodesh/router.ex). + + WHAT THIS DOES NOT CLAIM: + + - That the specifications are correct (only that they are + identifiable and unchanged from the witnessed state). + - That all instances will run the same code (only that all + instances running this code witness the same specifications). + - That hash equality across deployments proves no drift outside + this spec set (it does NOT — other artifacts not listed here + are not covered). + + WHAT TO DO ON DRIFT: + + If verify_identity returns {:error, ...}, the operator must: + 1. Run `manifest/0` to identify which file's hash mismatches. + 2. Inspect the diff against git to determine whether the change + was deliberate (an authorized update) or unauthorized + (a drift incident). + 3. If deliberate: update @expected_hash in this file, commit + both changes together with a message naming the spec change. + 4. If unauthorized: revert the file, investigate how the change + was introduced, fire chatzotzrot :assembly_leaders. + + The discipline: every change to @expected_hash should be traceable + in git to a corresponding spec change in the SAME COMMIT. A bare + hash-update commit with no spec-file change is itself a drift + signal — the operator updated the witness without updating what + is witnessed, which is exactly the failure mode this module + defends against. + """ + + # Repo-relative paths to the category-grade specification files. + # Sorted alphabetically so the canonical hash is independent of + # filesystem listing order. + # + # WHAT'S INCLUDED: + # - Halt rules (the cardinal-sin specifications — yehareg + # v'al ya'avor) + # - Compiler rules (the 13 middot of R. Yishmael — the + # inference protocol) + # - Foundational readings (eish_zarah, structure_not_content, + # lo_taaseh_lekha) + # - Architectural specs (PRD, architecture, vayakhel / + # construction, tamid / operations including the EXTERNAL + # NEDARIM section) + # - Build canon (BUILD_KORBAN, BUILD_AZAZEL) + # + # WHAT'S EXCLUDED: + # - shorashim_pekudei (economic model — operations) + # - shorashim_terumah (materials — operations) + # - Implementation code (execution-grade; the spec is what + # the execution implements) + # - Test files (these verify the spec; they are not the spec) + @spec_files [ + "BUILD_AZAZEL.md", + "BUILD_KORBAN.md", + "specs/eish_zarah_reading.md", + "specs/lo_taaseh_lekha.md", + "specs/shorashim_architecture.md", + "specs/shorashim_prd.md", + "specs/shorashim_tamid.md", + "specs/shorashim_vayakhel.md", + "specs/structure_not_content.md", + "talmudic_engine/src/halt/avodah_zarah.pl", + "talmudic_engine/src/halt/gilui_arayot.pl", + "talmudic_engine/src/halt/shfichut_damim.pl", + "talmudic_engine/src/middot/binyan_av.pl", + "talmudic_engine/src/middot/davar_yatza.pl", + "talmudic_engine/src/middot/gezerah_shavah.pl", + "talmudic_engine/src/middot/kal_vachomer.pl", + "talmudic_engine/src/middot/klal_ufrat.pl", + "talmudic_engine/src/middot/lamed_inyan.pl", + "talmudic_engine/src/middot/middot.pl", + "talmudic_engine/src/middot/shnei_ketuvim.pl" + ] + + # Repo root — directory containing the spec files. Configurable + # via :vav_pipeline application env so tests can point at + # fixtures. + @repo_root Application.compile_env( + :vav_pipeline, + :doorpost_repo_root, + Path.expand("../../..", __DIR__) + ) + + # The category-grade-identity hash. Updated when any spec file + # 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 "bea5dc0c0145ac0d46ffd08b328fbdbf19112f1aa4d93a3dc37bfd942e4e7490" + + @doc "Return the list of files that constitute the category-grade specification." + @spec files() :: [String.t()] + def files, do: @spec_files + + @doc "Return the baked-in expected hash." + @spec expected_hash() :: String.t() + def expected_hash, do: @expected_hash + + @doc "Return the configured repo root." + @spec repo_root() :: String.t() + def repo_root, do: @repo_root + + @doc """ + Compute the canonical hash over the current spec files. + + The hash combines per-file SHA-256s in canonical (sorted by path) + order. If any spec file is missing from the filesystem, returns + {:error, "missing spec file: "} — the spec set is + incomplete by definition; no partial hash is meaningful. + """ + @spec compute_identity() :: String.t() | {:error, String.t()} + def compute_identity do + items = manifest_items() + + case Enum.find(items, fn {_, h} -> h == :missing end) do + nil -> + combined = + items + |> Enum.map(fn {path, hash} -> "#{path}\t#{hash}" end) + |> Enum.join("\n") + + :crypto.hash(:sha256, combined) |> Base.encode16(case: :lower) + + {missing_path, _} -> + {:error, "missing spec file: #{missing_path}"} + end + end + + @doc """ + Return the manifest as a list of %{path, hash} maps. When a file + is missing, hash is the atom :missing. Used by the + /kodesh/specification_identity endpoint and by operators + diagnosing drift. + """ + @spec manifest() :: [%{path: String.t(), hash: String.t() | :missing}] + def manifest do + Enum.map(manifest_items(), fn {path, hash} -> + %{path: path, hash: hash} + end) + end + + defp manifest_items do + Enum.map(@spec_files, fn path -> + full_path = Path.join(@repo_root, path) + + case File.read(full_path) do + {:ok, contents} -> + {path, :crypto.hash(:sha256, contents) |> Base.encode16(case: :lower)} + + {:error, _} -> + {path, :missing} + end + end) + end + + @doc """ + Verify the current spec set matches the expected hash. + + Returns :ok when the hashes match. Returns {:error, reason} when + any spec file is missing OR the computed hash does not match the + baked-in @expected_hash. Same shape as L1Reader.verify_integrity/0. + + Lo tosif (Devarim 4:2) — addition or subtraction is detected + here, BEFORE it propagates into reception. + """ + @spec verify_identity() :: :ok | {:error, String.t()} + def verify_identity do + case compute_identity() do + {:error, reason} -> + {:error, "SPEC_INTEGRITY_FAILED: #{reason}"} + + hash when is_binary(hash) -> + if hash == @expected_hash do + :ok + else + {:error, + "SPEC_INTEGRITY_FAILED: hash mismatch — computed #{hash}, " <> + "expected #{@expected_hash}. Run " <> + "VavPipeline.Doorpost.manifest/0 to identify which file " <> + "drifted; inspect git for whether the change was deliberate."} + end + end + end +end diff --git a/vav_pipeline/test/doorpost_test.exs b/vav_pipeline/test/doorpost_test.exs new file mode 100644 index 00000000..5c70f515 --- /dev/null +++ b/vav_pipeline/test/doorpost_test.exs @@ -0,0 +1,253 @@ +defmodule VavPipeline.DoorpostTest do + @moduledoc """ + Tests for the doorpost-text identity verification (vehaya.md + §3.3, §6 item 3). Devarim 4:2's lo tosif enforced as a + computable property: any addition or subtraction across the + category-grade specification files produces a hash mismatch + detectable at /kodesh/specification_identity. + + These tests verify behavior at three layers: + 1. The pure functions (compute_identity, manifest, files) + 2. The verify_identity gate against a known-good hash + 3. Drift detection against a synthetic spec set in a temp dir + (uses application env override for repo_root) + """ + + use ExUnit.Case, async: false + + alias VavPipeline.Doorpost + + describe "files/0" do + test "returns the full list of category-grade spec files" do + files = Doorpost.files() + assert is_list(files) + # Sanity: includes the load-bearing artifacts + assert "BUILD_AZAZEL.md" in files + assert "BUILD_KORBAN.md" in files + assert "talmudic_engine/src/halt/avodah_zarah.pl" in files + assert "talmudic_engine/src/halt/gilui_arayot.pl" in files + assert "talmudic_engine/src/halt/shfichut_damim.pl" in files + assert "specs/eish_zarah_reading.md" in files + end + + test "explicitly excludes operations files (pekudei, terumah)" do + files = Doorpost.files() + refute "specs/shorashim_pekudei.md" in files + refute "specs/shorashim_terumah.md" in files + end + + test "list is sorted (canonical order independent of filesystem)" do + files = Doorpost.files() + assert files == Enum.sort(files) + end + + test "no file appears twice" do + files = Doorpost.files() + assert length(files) == length(Enum.uniq(files)) + end + end + + describe "manifest/0" do + test "returns one entry per file in @spec_files" do + manifest = Doorpost.manifest() + assert length(manifest) == length(Doorpost.files()) + + # Each entry is %{path, hash} + Enum.each(manifest, fn entry -> + assert Map.has_key?(entry, :path) + assert Map.has_key?(entry, :hash) + end) + end + + test "manifest paths match @spec_files order" do + manifest_paths = Enum.map(Doorpost.manifest(), & &1.path) + assert manifest_paths == Doorpost.files() + end + + test "each per-file hash is a 64-character hex SHA-256 (or :missing)" do + Enum.each(Doorpost.manifest(), fn entry -> + case entry.hash do + :missing -> + :ok + + hash when is_binary(hash) -> + assert String.length(hash) == 64 + assert String.match?(hash, ~r/^[0-9a-f]{64}$/) + end + end) + end + end + + describe "compute_identity/0 against the live repo" do + test "produces a stable 64-char hex hash" do + hash = Doorpost.compute_identity() + assert is_binary(hash), "compute_identity returned: #{inspect(hash)}" + assert String.length(hash) == 64 + assert String.match?(hash, ~r/^[0-9a-f]{64}$/) + end + + test "is deterministic — two calls return the same hash" do + hash1 = Doorpost.compute_identity() + hash2 = Doorpost.compute_identity() + assert hash1 == hash2 + end + end + + describe "verify_identity/0 against the live repo" do + test "returns :ok when the live spec set matches the baked hash" do + assert :ok = Doorpost.verify_identity() + end + end + + # ============================================================ + # Drift-detection tests against a synthetic spec set + # ============================================================ + # These tests build a fake spec set in a temp dir, point Doorpost + # at it via the application env, and verify drift detection works + # end-to-end. We can't override @spec_files (compile-time list); + # instead we set up a temp dir that has THE SAME files as the + # live spec set and verify the drift behaviors against THAT. + + describe "drift detection (synthetic spec set in temp dir)" do + setup do + # Build a temp dir mirror of the spec files + temp = System.tmp_dir!() |> Path.join("doorpost-test-#{:erlang.unique_integer([:positive])}") + File.mkdir_p!(temp) + + original_root = Application.get_env(:vav_pipeline, :doorpost_repo_root) + + Enum.each(Doorpost.files(), fn rel -> + src = Path.join(Doorpost.repo_root(), rel) + dst = Path.join(temp, rel) + File.mkdir_p!(Path.dirname(dst)) + if File.exists?(src), do: File.cp!(src, dst) + end) + + on_exit(fn -> + File.rm_rf!(temp) + # NOTE: cannot restore Application env override because + # @repo_root is read at compile time, not runtime. We + # work around by computing identity directly via a + # helper fn pointed at the temp dir — see below. + if original_root do + Application.put_env(:vav_pipeline, :doorpost_repo_root, original_root) + end + end) + + %{temp: temp} + end + + # Direct-compute helper that mirrors Doorpost.compute_identity + # but takes an explicit root. Used because @repo_root is baked + # at compile time. + defp compute_at(temp_root) do + items = + Enum.map(Doorpost.files(), fn rel -> + full_path = Path.join(temp_root, rel) + + case File.read(full_path) do + {:ok, contents} -> + {rel, :crypto.hash(:sha256, contents) |> Base.encode16(case: :lower)} + + {:error, _} -> + {rel, :missing} + end + end) + + case Enum.find(items, fn {_, h} -> h == :missing end) do + nil -> + combined = + items + |> Enum.map(fn {p, h} -> "#{p}\t#{h}" end) + |> Enum.join("\n") + + :crypto.hash(:sha256, combined) |> Base.encode16(case: :lower) + + {missing, _} -> + {:error, "missing spec file: #{missing}"} + end + end + + test "fresh mirror of the spec set produces the same hash as live", %{temp: temp} do + assert compute_at(temp) == Doorpost.compute_identity() + end + + test "modifying ANY one file changes the hash", %{temp: temp} do + original = compute_at(temp) + + # Append a single byte to one file + target = Path.join(temp, "talmudic_engine/src/halt/avodah_zarah.pl") + File.write!(target, File.read!(target) <> "\n") + + drifted = compute_at(temp) + refute original == drifted + end + + test "removing any one file fails compute_identity", %{temp: temp} do + target = Path.join(temp, "talmudic_engine/src/halt/avodah_zarah.pl") + File.rm!(target) + + assert {:error, "missing spec file: " <> _} = compute_at(temp) + end + + test "modifying a different file produces a different hash than modifying the first", %{ + temp: temp + } do + File.write!( + Path.join(temp, "talmudic_engine/src/halt/avodah_zarah.pl"), + File.read!(Path.join(temp, "talmudic_engine/src/halt/avodah_zarah.pl")) <> "\n" + ) + + hash_a = compute_at(temp) + + # Restore, then drift a different file + target_b = Path.join(temp, "specs/eish_zarah_reading.md") + File.write!(target_b, File.read!(target_b) <> "\n") + + hash_b = compute_at(temp) + + # Two distinct drifts → two distinct hashes + refute hash_a == hash_b + end + end + + describe "/kodesh/specification_identity HTTP endpoint" do + # Plug.Test integration. The endpoint is under /kodesh/* but is + # currently NOT auth-gated (the kodesh tier is mTLS in production + # via an upstream gateway, not in the application). + + import Plug.Test + alias VavPipeline.BigdeiKodesh.Router + + @opts Router.init([]) + + test "returns 200 when verify_identity is :ok" do + conn = conn(:get, "/kodesh/specification_identity") |> Router.call(@opts) + + assert conn.status == 200 + body = Jason.decode!(conn.resp_body) + assert body["status"] == "verified" + assert is_binary(body["computed_hash"]) + assert is_binary(body["expected_hash"]) + assert body["computed_hash"] == body["expected_hash"] + assert is_integer(body["file_count"]) + assert body["file_count"] > 0 + assert is_list(body["manifest"]) + assert is_list(body["torah_basis"]) + assert "Devarim 4:2 (lo tosif)" in body["torah_basis"] + end + + test "manifest in response carries per-file hashes" do + conn = conn(:get, "/kodesh/specification_identity") |> Router.call(@opts) + body = Jason.decode!(conn.resp_body) + + manifest = body["manifest"] + assert length(manifest) == length(Doorpost.files()) + + Enum.each(manifest, fn entry -> + assert Map.has_key?(entry, "path") + assert Map.has_key?(entry, "hash") + end) + end + end +end