L3 Oral Tradition: PostgreSQL persistence layer for interpretations, minority opinions, teiku
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
adc3e41679
commit
d34e2799ac
14 files changed
+722
-3
No files matched your search
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+16
-2
@@ -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
|
||||
@@ -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"},
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user