From d34e2799acf89afd20db35cd2b0940a4d8598647 Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Tue, 17 Mar 2026 09:43:44 -0400 Subject: [PATCH] L3 Oral Tradition: PostgreSQL persistence layer for interpretations, minority opinions, teiku MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devarim 17:9 — v'darashta v'higidu lekha et d'var hamishpat. The inquiry requires that what is found is RECORDED. - Ecto Repo with graceful degradation (maybe_add_repo, like other engines) - Migration: interpretations, minority_opinions, teiku_registry tables - Append-only enforcement via REVOKE UPDATE/DELETE - Includes eish_zarah fields: coupling_trace, grounding_type, challenge_sequence - Ecto schemas: Interpretation, MinorityOpinion, TeikuRecord - Changeset validations: status inclusion, panel_size > 0, grounding_type enum - TeikuRecord.resolve_changeset restricts updates to resolved_at/resolution only - L3.Store bridge module: record, query, count operations - L3 tests tagged @moduletag :l3, excluded by default (no local PostgreSQL) - Enable with: SHORASHIM_L3=1 mix test --include l3 - All 76 existing Elixir tests pass, 195 Prolog tests pass, 33 Haskell tests pass Co-Authored-By: Claude Opus 4.6 --- vav_pipeline/config/config.exs | 9 + vav_pipeline/config/prod.exs | 7 + vav_pipeline/config/test.exs | 10 + vav_pipeline/lib/vav_pipeline/application.ex | 19 ++ .../lib/vav_pipeline/l3/interpretation.ex | 51 ++++ .../lib/vav_pipeline/l3/minority_opinion.ex | 36 +++ vav_pipeline/lib/vav_pipeline/l3/store.ex | 130 +++++++++ .../lib/vav_pipeline/l3/teiku_record.ex | 56 ++++ vav_pipeline/lib/vav_pipeline/repo.ex | 16 ++ vav_pipeline/mix.exs | 18 +- vav_pipeline/mix.lock | 6 + .../20260316000001_create_l3_tables.exs | 105 ++++++++ vav_pipeline/test/l3_store_test.exs | 253 ++++++++++++++++++ vav_pipeline/test/test_helper.exs | 9 +- 14 files changed, 722 insertions(+), 3 deletions(-) create mode 100644 vav_pipeline/lib/vav_pipeline/l3/interpretation.ex create mode 100644 vav_pipeline/lib/vav_pipeline/l3/minority_opinion.ex create mode 100644 vav_pipeline/lib/vav_pipeline/l3/store.ex create mode 100644 vav_pipeline/lib/vav_pipeline/l3/teiku_record.ex create mode 100644 vav_pipeline/lib/vav_pipeline/repo.ex create mode 100644 vav_pipeline/priv/repo/migrations/20260316000001_create_l3_tables.exs create mode 100644 vav_pipeline/test/l3_store_test.exs diff --git a/vav_pipeline/config/config.exs b/vav_pipeline/config/config.exs index bf0bbe81..a41e0f2f 100644 --- a/vav_pipeline/config/config.exs +++ b/vav_pipeline/config/config.exs @@ -18,5 +18,14 @@ config :vav_pipeline, # Talmudic Engine (Prolog) — Unix domain socket path talmudic_socket_path: "/tmp/shorashim/talmudic.sock" +# L3 — Oral Tradition (PostgreSQL) +# Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE. +config :vav_pipeline, ecto_repos: [VavPipeline.Repo] + +config :vav_pipeline, VavPipeline.Repo, + database: "shorashim_dev", + hostname: "localhost", + pool_size: 10 + # Import environment specific config import_config "#{config_env()}.exs" diff --git a/vav_pipeline/config/prod.exs b/vav_pipeline/config/prod.exs index 9b5a93a1..3f923b81 100644 --- a/vav_pipeline/config/prod.exs +++ b/vav_pipeline/config/prod.exs @@ -8,3 +8,10 @@ config :vav_pipeline, manna_provision_file: "/var/lib/shorashim/manna/today.json", cycle_boundary_check_interval_ms: 60_000, shema_context_cache_ttl_ms: 0 + +# L3 — Production PostgreSQL +# Construction §3.8: l3_database_url from environment variable +# The kohen/DBA sets the connection string via DATABASE_URL +config :vav_pipeline, VavPipeline.Repo, + url: System.get_env("DATABASE_URL"), + pool_size: 20 diff --git a/vav_pipeline/config/test.exs b/vav_pipeline/config/test.exs index 5a173337..c8a8c4e2 100644 --- a/vav_pipeline/config/test.exs +++ b/vav_pipeline/config/test.exs @@ -7,3 +7,13 @@ config :vav_pipeline, l1_mount_point: "../l1", manna_provision_file: "test/fixtures/manna/today.json", cycle_boundary_check_interval_ms: :timer.hours(24) + +# L3 test database — separate from dev, uses sandbox pool +# Repo start disabled by default — L3 tests need PostgreSQL running locally +# Enable: SHORASHIM_L3=1 mix test --include l3 +config :vav_pipeline, VavPipeline.Repo, + database: "shorashim_test#{System.get_env("MIX_TEST_PARTITION")}", + hostname: "localhost", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: System.schedulers_online() * 2, + start: System.get_env("SHORASHIM_L3") != nil diff --git a/vav_pipeline/lib/vav_pipeline/application.ex b/vav_pipeline/lib/vav_pipeline/application.ex index 88237c4e..40d995f9 100644 --- a/vav_pipeline/lib/vav_pipeline/application.ex +++ b/vav_pipeline/lib/vav_pipeline/application.ex @@ -3,6 +3,7 @@ defmodule VavPipeline.Application do OTP Application for the Vav Pipeline. Starts the supervision tree with: + - Repo (L3 PostgreSQL — oral tradition persistence) - ShabbatTimer (mandatory cessation monitor) - ShoreshEnginePort (Erlang Port to Haskell engine, if binary found) - TalmudicEngineSocket (Unix socket to Prolog engine) @@ -26,6 +27,7 @@ defmodule VavPipeline.Application do [ {VavPipeline.ShabbatTimer, []} ] + |> maybe_add_repo() |> maybe_add_shoresh_port(shoresh_binary, shoresh_cwd) |> maybe_add_talmudic_socket(talmudic_socket) @@ -33,6 +35,23 @@ defmodule VavPipeline.Application do Supervisor.start_link(children, opts) end + defp maybe_add_repo(children) do + repo_config = Application.get_env(:vav_pipeline, VavPipeline.Repo) + + cond do + is_nil(repo_config) -> + Logger.warning("VavPipeline.Repo: no config found — running without L3 persistence") + children + + repo_config[:start] == false -> + Logger.info("VavPipeline.Repo: start disabled — running without L3 persistence") + children + + true -> + [VavPipeline.Repo | children] + end + end + defp maybe_add_shoresh_port(children, binary, cwd) do case System.find_executable(binary) do nil -> diff --git a/vav_pipeline/lib/vav_pipeline/l3/interpretation.ex b/vav_pipeline/lib/vav_pipeline/l3/interpretation.ex new file mode 100644 index 00000000..41cae577 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/l3/interpretation.ex @@ -0,0 +1,51 @@ +defmodule VavPipeline.L3.Interpretation do + @moduledoc """ + L3 schema for recorded interpretations. + + Architecture §3.7: Each interpretation records the case, the reasoning, + the Torah references that grounded it, the panel vote, and the status. + + Append-only: once inserted, never updated or deleted. + The oral tradition is written once. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "interpretations" do + field :case_ref, :string + field :interpretation, :string + field :status, :string + field :reasoning, :string + field :torah_references, {:array, :string}, default: [] + field :roots_involved, {:array, :string}, default: [] + field :panel_size, :integer + field :vote_count_for, :integer + field :vote_count_against, :integer + field :shema_context_hash, :string + field :processing_component, :string + field :coupling_trace, {:array, :string}, default: [] + + has_many :minority_opinions, VavPipeline.L3.MinorityOpinion + + timestamps(type: :utc_datetime, updated_at: false) + end + + @required_fields [ + :case_ref, :interpretation, :status, :reasoning, + :torah_references, :roots_involved, + :panel_size, :vote_count_for, :vote_count_against + ] + @optional_fields [:shema_context_hash, :processing_component, :coupling_trace] + @valid_statuses ~w(accepted rejected teiku) + + def changeset(interpretation, attrs) do + interpretation + |> cast(attrs, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> validate_inclusion(:status, @valid_statuses) + |> validate_number(:panel_size, greater_than: 0) + |> validate_number(:vote_count_for, greater_than_or_equal_to: 0) + |> validate_number(:vote_count_against, greater_than_or_equal_to: 0) + end +end diff --git a/vav_pipeline/lib/vav_pipeline/l3/minority_opinion.ex b/vav_pipeline/lib/vav_pipeline/l3/minority_opinion.ex new file mode 100644 index 00000000..26a5eb31 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/l3/minority_opinion.ex @@ -0,0 +1,36 @@ +defmodule VavPipeline.L3.MinorityOpinion do + @moduledoc """ + L3 schema for preserved dissent. + + Architecture §3.7: Minority opinions are recorded alongside majority + decisions. "Rabbi X says... but Rabbi Y says..." The dissent is never + discarded. It may be needed when conditions change. + + Append-only: once inserted, never updated or deleted. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "minority_opinions" do + field :dissenting_reasoning, :string + field :torah_references, {:array, :string}, default: [] + field :grounding_type, :string + + belongs_to :interpretation, VavPipeline.L3.Interpretation + + timestamps(type: :utc_datetime, updated_at: false) + end + + @required_fields [:dissenting_reasoning, :torah_references, :interpretation_id] + @optional_fields [:grounding_type] + @valid_grounding_types ~w(achorai panim) + + def changeset(opinion, attrs) do + opinion + |> cast(attrs, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> validate_inclusion(:grounding_type, @valid_grounding_types ++ [nil]) + |> foreign_key_constraint(:interpretation_id) + end +end diff --git a/vav_pipeline/lib/vav_pipeline/l3/store.ex b/vav_pipeline/lib/vav_pipeline/l3/store.ex new file mode 100644 index 00000000..63824dc2 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/l3/store.ex @@ -0,0 +1,130 @@ +defmodule VavPipeline.L3.Store do + @moduledoc """ + L3 Store — bridge between the reasoning engine and persistent storage. + + Architecture §3.7, Construction §3.8: + Every write is an INSERT. Never UPDATE. Never DELETE. + + V'darashta v'higidu lekha et d'var hamishpat — Devarim 17:9. + You shall inquire, and they shall declare to you the matter of judgment. + The inquiry requires that what is found is RECORDED. The Talmud itself + is the record of the inquiry. Without L3, the system has the capacity + to deliberate but not to accumulate learning. + + This module provides three operations: + 1. record_interpretation/1 — write a decided case + 2. record_minority/1 — write a dissenting opinion + 3. record_teiku/1 — write an unresolvable tension + """ + + alias VavPipeline.Repo + alias VavPipeline.L3.{Interpretation, MinorityOpinion, TeikuRecord} + + import Ecto.Query + + @doc """ + Record an interpretation (decided case) to L3. + + Accepts a map with the fields from the Interpretation schema. + Returns {:ok, interpretation} or {:error, changeset}. + """ + def record_interpretation(attrs) when is_map(attrs) do + %Interpretation{} + |> Interpretation.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Record a minority opinion to L3. + + The interpretation_id must reference an existing interpretation. + Returns {:ok, opinion} or {:error, changeset}. + """ + def record_minority(attrs) when is_map(attrs) do + %MinorityOpinion{} + |> MinorityOpinion.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Record an unresolvable tension (teiku) to L3. + + Returns {:ok, record} or {:error, changeset}. + """ + def record_teiku(attrs) when is_map(attrs) do + %TeikuRecord{} + |> TeikuRecord.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Resolve a teiku — update resolved_at and resolution. + + This is the ONLY permitted update operation in L3. + Only resolved_at and resolution fields may be changed. + """ + def resolve_teiku(teiku_id, resolution_text) when is_integer(teiku_id) do + case Repo.get(TeikuRecord, teiku_id) do + nil -> + {:error, :not_found} + + record -> + record + |> TeikuRecord.resolve_changeset(%{ + resolved_at: DateTime.utc_now(), + resolution: resolution_text + }) + |> Repo.update() + end + end + + # === QUERIES === + + @doc "Get all interpretations for a case ref." + def get_interpretations(case_ref) do + Interpretation + |> where([i], i.case_ref == ^case_ref) + |> order_by([i], asc: i.inserted_at) + |> Repo.all() + end + + @doc "Get an interpretation with its minority opinions." + def get_interpretation_with_dissent(id) do + Interpretation + |> Repo.get(id) + |> Repo.preload(:minority_opinions) + end + + @doc "Get all unresolved teiku records." + def get_unresolved_teiku do + TeikuRecord + |> where([t], is_nil(t.resolved_at)) + |> order_by([t], asc: t.inserted_at) + |> Repo.all() + end + + @doc "Get all teiku records for a case ref." + def get_teiku(case_ref) do + TeikuRecord + |> where([t], t.case_ref == ^case_ref) + |> order_by([t], asc: t.inserted_at) + |> Repo.all() + end + + @doc "Count total interpretations in L3." + def count_interpretations do + Repo.aggregate(Interpretation, :count, :id) + end + + @doc "Count total unresolved teiku." + def count_unresolved_teiku do + TeikuRecord + |> where([t], is_nil(t.resolved_at)) + |> Repo.aggregate(:count, :id) + end + + @doc "Count total minority opinions." + def count_minority_opinions do + Repo.aggregate(MinorityOpinion, :count, :id) + end +end diff --git a/vav_pipeline/lib/vav_pipeline/l3/teiku_record.ex b/vav_pipeline/lib/vav_pipeline/l3/teiku_record.ex new file mode 100644 index 00000000..84042d71 --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/l3/teiku_record.ex @@ -0,0 +1,56 @@ +defmodule VavPipeline.L3.TeikuRecord do + @moduledoc """ + L3 schema for unresolvable tensions. + + Architecture §3.7: When resolution fails and teiku is declared, + both sides are preserved. The tension is recorded. It may be + resolved later — or never. + + Tishbi Yetaretz Kushyot V'Abayot — Elijah will resolve + difficulties and problems. Until then, the tension stands. + + Unlike interpretations and minority_opinions, teiku records + allow UPDATE on resolved_at and resolution fields only. + All other fields are append-only. + """ + + use Ecto.Schema + import Ecto.Changeset + + schema "teiku_registry" do + field :case_ref, :string + field :tension_description, :string + field :interpretation_a, :string + field :interpretation_b, :string + field :torah_refs_a, {:array, :string}, default: [] + field :torah_refs_b, {:array, :string}, default: [] + field :challenge_sequence, {:array, :string}, default: [] + field :resolved_at, :utc_datetime + field :resolution, :string + + timestamps(type: :utc_datetime, updated_at: false) + end + + @required_fields [ + :case_ref, :tension_description, + :interpretation_a, :interpretation_b, + :torah_refs_a, :torah_refs_b + ] + @optional_fields [:challenge_sequence, :resolved_at, :resolution] + + def changeset(record, attrs) do + record + |> cast(attrs, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + end + + @doc """ + Resolution changeset — only allows updating resolved_at and resolution. + All other fields are immutable once written. + """ + def resolve_changeset(record, attrs) do + record + |> cast(attrs, [:resolved_at, :resolution]) + |> validate_required([:resolved_at, :resolution]) + end +end diff --git a/vav_pipeline/lib/vav_pipeline/repo.ex b/vav_pipeline/lib/vav_pipeline/repo.ex new file mode 100644 index 00000000..33bd754c --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/repo.ex @@ -0,0 +1,16 @@ +defmodule VavPipeline.Repo do + @moduledoc """ + L3 — Oral Tradition. PostgreSQL repository. + + Architecture §3.7: Append-only policy enforced at database level. + INSERT allowed. UPDATE and DELETE prohibited (via REVOKE on shorashim_app role). + + The oral tradition is written once and never erased. + Luchot v'shivrei luchot munachim ba'aron — both the whole tablets + and the broken tablets are in the aron. What is recorded stays recorded. + """ + + use Ecto.Repo, + otp_app: :vav_pipeline, + adapter: Ecto.Adapters.Postgres +end diff --git a/vav_pipeline/mix.exs b/vav_pipeline/mix.exs index c1916336..ca30310b 100644 --- a/vav_pipeline/mix.exs +++ b/vav_pipeline/mix.exs @@ -7,7 +7,8 @@ defmodule VavPipeline.MixProject do version: "0.1.0", elixir: "~> 1.16.1", start_permanent: Mix.env() == :prod, - deps: deps() + deps: deps(), + aliases: aliases() ] end @@ -18,11 +19,24 @@ defmodule VavPipeline.MixProject do ] end + defp aliases do + [ + setup: ["deps.get", "ecto.setup"], + "ecto.setup": ["ecto.create", "ecto.migrate"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["test"] + ] + end + # Construction spec §1.5 — exact version pins defp deps do [ {:jason, "~> 1.4.1"}, - {:sanhedrin, path: "../sanhedrin", manager: :rebar3} + {:sanhedrin, path: "../sanhedrin", manager: :rebar3}, + # L3 — Oral Tradition (PostgreSQL append-only) + # Architecture §3.7, Construction §1.5 + {:ecto_sql, "~> 3.11.1"}, + {:postgrex, "~> 0.17.4"} ] end end diff --git a/vav_pipeline/mix.lock b/vav_pipeline/mix.lock index bc91c873..ec841427 100644 --- a/vav_pipeline/mix.lock +++ b/vav_pipeline/mix.lock @@ -1,4 +1,10 @@ %{ + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, + "ecto_sql": {:hex, :ecto_sql, "3.11.3", "4eb7348ff8101fbc4e6bbc5a4404a24fecbe73a3372d16569526b0cf34ebc195", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e5f36e3d736b99c7fee3e631333b8394ade4bafe9d96d35669fca2d81c2be928"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, + "postgrex": {:hex, :postgrex, "0.17.5", "0483d054938a8dc069b21bdd636bf56c487404c241ce6c319c1f43588246b281", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "50b8b11afbb2c4095a3ba675b4f055c416d0f3d7de6633a595fc131a828a67eb"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, } diff --git a/vav_pipeline/priv/repo/migrations/20260316000001_create_l3_tables.exs b/vav_pipeline/priv/repo/migrations/20260316000001_create_l3_tables.exs new file mode 100644 index 00000000..c43a14ec --- /dev/null +++ b/vav_pipeline/priv/repo/migrations/20260316000001_create_l3_tables.exs @@ -0,0 +1,105 @@ +defmodule VavPipeline.Repo.Migrations.CreateL3Tables do + @moduledoc """ + L3 — Oral Tradition. Three tables, append-only. + + Architecture §3.7: + interpretations — case decisions with reasoning and Torah references + minority_opinions — preserved dissent (foreign key to interpretations) + teiku_registry — unresolvable tensions with both sides preserved + + Construction §3.8 (L3 Write Protocol): + Every write is an INSERT. Never UPDATE. Never DELETE. + The application user has INSERT-only permissions. + Every INSERT includes shema_context_hash and processing_component. + + Append-only enforcement via REVOKE is applied after table creation. + The shorashim_app role must be created separately (by the kohen/DBA). + """ + + use Ecto.Migration + + def up do + # === interpretations === + create table(:interpretations) do + add :case_ref, :text, null: false + add :interpretation, :text, null: false + add :status, :text, null: false + add :reasoning, :text, null: false + add :torah_references, {:array, :text}, null: false, default: [] + add :roots_involved, {:array, :text}, null: false, default: [] + add :panel_size, :integer, null: false + add :vote_count_for, :integer, null: false + add :vote_count_against, :integer, null: false + # Construction §3.8 additions + add :shema_context_hash, :text + add :processing_component, :text + # Coupling trace from eish_zarah_reading.md + add :coupling_trace, {:array, :text}, default: [] + + timestamps(type: :utc_datetime, updated_at: false) + end + + # Status constraint + create constraint(:interpretations, :valid_status, + check: "status IN ('accepted', 'rejected', 'teiku')") + + create index(:interpretations, [:case_ref]) + create index(:interpretations, [:status]) + + # === minority_opinions === + create table(:minority_opinions) do + add :interpretation_id, references(:interpretations, on_delete: :restrict), null: false + add :dissenting_reasoning, :text, null: false + add :torah_references, {:array, :text}, null: false, default: [] + # Grounding type from eish_zarah_reading.md (achorai/panim) + add :grounding_type, :text + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:minority_opinions, [:interpretation_id]) + + # === teiku_registry === + create table(:teiku_registry) do + add :case_ref, :text, null: false + add :tension_description, :text, null: false + add :interpretation_a, :text, null: false + add :interpretation_b, :text, null: false + add :torah_refs_a, {:array, :text}, null: false, default: [] + add :torah_refs_b, {:array, :text}, null: false, default: [] + # Challenge sequence from eish_zarah_reading.md + add :challenge_sequence, {:array, :text}, default: [] + # Resolution fields — teiku may be resolved later (or never) + add :resolved_at, :utc_datetime + add :resolution, :text + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:teiku_registry, [:case_ref]) + + # === APPEND-ONLY ENFORCEMENT === + # These execute only if the shorashim_app role exists. + # In dev/test, the migration runs as the superuser and these are skipped. + # In production, the DBA creates the role first. + execute """ + DO $$ + BEGIN + IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'shorashim_app') THEN + REVOKE UPDATE, DELETE ON interpretations FROM shorashim_app; + REVOKE UPDATE, DELETE ON minority_opinions FROM shorashim_app; + REVOKE DELETE ON teiku_registry FROM shorashim_app; + -- teiku_registry allows UPDATE only on resolved_at and resolution + -- (Enforced at application level — Ecto schema validates) + END IF; + END + $$; + """ + end + + def down do + drop table(:teiku_registry) + drop table(:minority_opinions) + drop table(:interpretations) + end +end diff --git a/vav_pipeline/test/l3_store_test.exs b/vav_pipeline/test/l3_store_test.exs new file mode 100644 index 00000000..ef9f9ed5 --- /dev/null +++ b/vav_pipeline/test/l3_store_test.exs @@ -0,0 +1,253 @@ +defmodule VavPipeline.L3.StoreTest do + @moduledoc """ + Tests for L3 Oral Tradition persistence. + + Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE. + Luchot v'shivrei luchot munachim ba'aron. + """ + + use ExUnit.Case, async: true + + @moduletag :l3 + + alias VavPipeline.L3.Store + alias VavPipeline.L3.{Interpretation, MinorityOpinion, TeikuRecord} + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo) + :ok + end + + # === INTERPRETATION RECORDING === + + @valid_interpretation %{ + case_ref: "shabbat_cooking_case_001", + interpretation: "Cooking on Shabbat is forbidden under melacha prohibition", + status: "accepted", + reasoning: "Shemot 35:3 explicitly prohibits kindling fire on Shabbat; cooking requires fire", + torah_references: ["Shemot 35:3", "Shemot 20:10"], + roots_involved: ["בער", "אש", "מלאכ"], + panel_size: 3, + vote_count_for: 2, + vote_count_against: 1, + coupling_trace: ["coupled(root(בער), verse(shemot, 35, 3))"] + } + + test "record_interpretation inserts a valid interpretation" do + assert {:ok, %Interpretation{} = interp} = Store.record_interpretation(@valid_interpretation) + assert interp.id != nil + assert interp.case_ref == "shabbat_cooking_case_001" + assert interp.status == "accepted" + assert interp.panel_size == 3 + assert interp.vote_count_for == 2 + assert interp.vote_count_against == 1 + assert interp.coupling_trace == ["coupled(root(בער), verse(shemot, 35, 3))"] + assert interp.inserted_at != nil + end + + test "record_interpretation rejects missing required fields" do + assert {:error, changeset} = Store.record_interpretation(%{case_ref: "incomplete"}) + refute changeset.valid? + end + + test "record_interpretation rejects invalid status" do + attrs = Map.put(@valid_interpretation, :status, "maybe") + assert {:error, changeset} = Store.record_interpretation(attrs) + assert %{status: _} = errors_on(changeset) + end + + test "record_interpretation rejects non-positive panel_size" do + attrs = Map.put(@valid_interpretation, :panel_size, 0) + assert {:error, changeset} = Store.record_interpretation(attrs) + assert %{panel_size: _} = errors_on(changeset) + end + + test "record_interpretation accepts rejected status" do + attrs = Map.put(@valid_interpretation, :status, "rejected") + assert {:ok, %Interpretation{status: "rejected"}} = Store.record_interpretation(attrs) + end + + test "record_interpretation accepts teiku status" do + attrs = Map.put(@valid_interpretation, :status, "teiku") + assert {:ok, %Interpretation{status: "teiku"}} = Store.record_interpretation(attrs) + end + + # === MINORITY OPINION RECORDING === + + test "record_minority inserts a dissenting opinion" do + {:ok, interp} = Store.record_interpretation(@valid_interpretation) + + attrs = %{ + interpretation_id: interp.id, + dissenting_reasoning: "Cooking for pikuach nefesh overrides Shabbat prohibition", + torah_references: ["Vayikra 18:5"], + grounding_type: "achorai" + } + + assert {:ok, %MinorityOpinion{} = opinion} = Store.record_minority(attrs) + assert opinion.id != nil + assert opinion.interpretation_id == interp.id + assert opinion.grounding_type == "achorai" + end + + test "record_minority accepts panim grounding type" do + {:ok, interp} = Store.record_interpretation(@valid_interpretation) + + attrs = %{ + interpretation_id: interp.id, + dissenting_reasoning: "Domain-level tension with healing laws", + torah_references: ["Devarim 4:9"], + grounding_type: "panim" + } + + assert {:ok, %MinorityOpinion{grounding_type: "panim"}} = Store.record_minority(attrs) + end + + test "record_minority rejects missing dissenting_reasoning" do + {:ok, interp} = Store.record_interpretation(@valid_interpretation) + + attrs = %{ + interpretation_id: interp.id, + torah_references: ["Vayikra 18:5"] + } + + assert {:error, changeset} = Store.record_minority(attrs) + refute changeset.valid? + end + + # === TEIKU RECORDING === + + @valid_teiku %{ + case_ref: "shabbat_pikuach_nefesh_tension", + tension_description: "Shabbat prohibition vs. pikuach nefesh override", + interpretation_a: "Shabbat is absolute — ein docheh Shabbat", + interpretation_b: "Pikuach nefesh overrides all — v'chai bahem", + torah_refs_a: ["Shemot 35:3", "Shemot 20:10"], + torah_refs_b: ["Vayikra 18:5"], + challenge_sequence: ["mitzvah_tension", "kal_vachomer_tension"] + } + + test "record_teiku inserts an unresolvable tension" do + assert {:ok, %TeikuRecord{} = record} = Store.record_teiku(@valid_teiku) + assert record.id != nil + assert record.case_ref == "shabbat_pikuach_nefesh_tension" + assert record.resolved_at == nil + assert record.resolution == nil + assert record.challenge_sequence == ["mitzvah_tension", "kal_vachomer_tension"] + end + + test "resolve_teiku updates only resolved_at and resolution" do + {:ok, record} = Store.record_teiku(@valid_teiku) + assert record.resolved_at == nil + + assert {:ok, resolved} = Store.resolve_teiku(record.id, "Pikuach nefesh overrides — Yoma 85b") + assert resolved.resolved_at != nil + assert resolved.resolution == "Pikuach nefesh overrides — Yoma 85b" + # Original fields unchanged + assert resolved.case_ref == "shabbat_pikuach_nefesh_tension" + assert resolved.tension_description == "Shabbat prohibition vs. pikuach nefesh override" + end + + test "resolve_teiku returns error for non-existent id" do + assert {:error, :not_found} = Store.resolve_teiku(999_999, "no such record") + end + + # === QUERIES === + + test "get_interpretations returns interpretations for a case ref" do + Store.record_interpretation(@valid_interpretation) + Store.record_interpretation(Map.put(@valid_interpretation, :status, "rejected")) + + results = Store.get_interpretations("shabbat_cooking_case_001") + assert length(results) == 2 + end + + test "get_interpretations returns empty list for unknown case" do + assert [] == Store.get_interpretations("nonexistent_case") + end + + test "get_interpretation_with_dissent preloads minority opinions" do + {:ok, interp} = Store.record_interpretation(@valid_interpretation) + + Store.record_minority(%{ + interpretation_id: interp.id, + dissenting_reasoning: "Dissent one", + torah_references: ["Vayikra 18:5"] + }) + + Store.record_minority(%{ + interpretation_id: interp.id, + dissenting_reasoning: "Dissent two", + torah_references: ["Devarim 4:9"], + grounding_type: "panim" + }) + + loaded = Store.get_interpretation_with_dissent(interp.id) + assert length(loaded.minority_opinions) == 2 + end + + test "get_unresolved_teiku returns only unresolved records" do + {:ok, t1} = Store.record_teiku(@valid_teiku) + {:ok, _t2} = Store.record_teiku(Map.put(@valid_teiku, :case_ref, "another_tension")) + + Store.resolve_teiku(t1.id, "Resolved by gemara") + + unresolved = Store.get_unresolved_teiku() + assert length(unresolved) == 1 + assert hd(unresolved).case_ref == "another_tension" + end + + test "get_teiku returns teiku records for a case ref" do + Store.record_teiku(@valid_teiku) + results = Store.get_teiku("shabbat_pikuach_nefesh_tension") + assert length(results) == 1 + end + + # === COUNTS === + + test "count functions return correct totals" do + assert Store.count_interpretations() == 0 + assert Store.count_unresolved_teiku() == 0 + assert Store.count_minority_opinions() == 0 + + {:ok, interp} = Store.record_interpretation(@valid_interpretation) + Store.record_minority(%{ + interpretation_id: interp.id, + dissenting_reasoning: "Counted dissent", + torah_references: ["Bereishit 1:1"] + }) + Store.record_teiku(@valid_teiku) + + assert Store.count_interpretations() == 1 + assert Store.count_minority_opinions() == 1 + assert Store.count_unresolved_teiku() == 1 + end + + # === APPEND-ONLY PRINCIPLE === + + test "multiple interpretations for same case are preserved — never overwritten" do + Store.record_interpretation(@valid_interpretation) + Store.record_interpretation(Map.merge(@valid_interpretation, %{ + interpretation: "Alternative: warming food placed before Shabbat is permitted", + status: "rejected", + vote_count_for: 1, + vote_count_against: 2 + })) + + all = Store.get_interpretations("shabbat_cooking_case_001") + assert length(all) == 2 + + statuses = Enum.map(all, & &1.status) + assert "accepted" in statuses + assert "rejected" in statuses + end + + # Helper to extract error messages from changeset + defp errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Regex.replace(~r"%{(\w+)}", msg, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/vav_pipeline/test/test_helper.exs b/vav_pipeline/test/test_helper.exs index 869559e7..27f3f356 100644 --- a/vav_pipeline/test/test_helper.exs +++ b/vav_pipeline/test/test_helper.exs @@ -1 +1,8 @@ -ExUnit.start() +# L3 tests require PostgreSQL — excluded by default +# Run with: SHORASHIM_L3=1 mix test --include l3 +ExUnit.start(exclude: [:l3]) + +if System.get_env("SHORASHIM_L3") do + {:ok, _} = VavPipeline.Repo.start_link() + Ecto.Adapters.SQL.Sandbox.mode(VavPipeline.Repo, :manual) +end