vav_pipeline: commit 7 — L3 azazel_records + cross-language ingest GenServer
Pass 4 / Finding F, commit 7 of 10 from BUILD_AZAZEL.md. The
operator-internal half of property 9 (observable disposal). Crosses
the Python/Elixir boundary: the Python orchestrator drops completed
disposal records as JSON files; this commit's GenServer reads them
and persists to L3.
The public half (a /peah/azazel_log read endpoint) lands in commit 8;
the routing-from-Korban + Chatzotzrot wiring lands in commit 9. After
those, the Azazel Processor is end-to-end functional.
CREATED:
vav_pipeline/priv/repo/migrations/20260428000001_create_azazel_records.exs
azazel_records table — append-only, parallel to interpretations.
Columns: case_ref, vidui (jsonb), routing, authorized_by,
shaliach, triggering_call, goral_outcome_id, disposed_at.
Indexes on case_ref, disposed_at, shaliach.
Property-12-defending CHECK constraint:
routing_must_be_eretz_gezerah CHECK (routing = 'eretz_gezerah').
A future migration that wants to add a destination handle will
fail this check; the operator must explicitly justify removing
the constraint, which is the right friction.
Append-only enforcement matches interpretations: REVOKE
UPDATE/DELETE on the shorashim_app role.
vav_pipeline/lib/vav_pipeline/l3/azazel_record.ex
Ecto schema mirroring the Python AzazelRecord.to_storage_dict()
shape. Changeset validates:
- all required fields present
- routing == "eretz_gezerah" (mirrors the DB CHECK at app layer)
- vidui is a map with avonot/peshaim/chatot/confessor/
confessed_at slots present
- vidui's three categories not all empty (mirrors the Python
Vidui.__post_init__ EmptyConfessionError)
vav_pipeline/lib/vav_pipeline/azazel_ingest.ex ~190 lines
GenServer that polls a configurable pending-l3 directory
(default /tmp/shorashim/azazel/pending_l3). For each *.json file:
1. Read + JSON-parse
2. Translate Python serialization → Ecto attrs
3. Call recorder (default L3.Store.record_disposal/1)
4. On success, shred the source file (property-10 follow-through)
5. On failure, leave file in place for retry; log warning
Recorder is injectable for tests — mock recorder lets us verify
the pipeline without PostgreSQL.
Poll interval default 5s, configurable. Polling rather than
file_system events to keep the dependency surface minimal; the
Azazel cadence is exceptional content, not high-throughput.
Not added to the supervision tree in this commit — the producer
side (Python orchestrator dropping files) is wired in commit 9.
Adding to the supervisor before there's a producer means a
poller doing nothing useful. Operator can start manually for
early testing: VavPipeline.AzazelIngest.start_link(...).
CHANGED:
vav_pipeline/lib/vav_pipeline/l3/store.ex
+ record_disposal/1
Inserts an AzazelRecord. Returns {:ok, record} or
{:error, changeset}.
+ count_disposals/1
Total or since-a-timestamp count. Used by the public
observability endpoint (commit 8).
+ disposal_summary/1
Returns categorical info per disposal — case_ref, shaliach,
triggering_call, disposed_at, categories_filled (which of
avonot/peshaim/chatot had any items, NOT the items
themselves). PROPERTY-9 PRIVACY BOUNDARY at the public
side: items are operator-internal only, accessed via
future /kodesh/* endpoints.
TESTS:
vav_pipeline/test/azazel_ingest_test.exs 15 tests, no DB
Happy path:
- poll_now ingests valid file and shreds it
- multiple files processed in sorted order
- vidui preserved as map (jsonb shape)
Retry semantics:
- recorder error → file left in place + state.errors++
- malformed JSON → file left in place
- missing disposed_at → file left in place
State accounting:
- polls_completed, records_ingested, errors all tracked
- state survives multiple polls
- non-json files ignored
Changeset (no DB):
- valid attrs
- rejects routing != eretz_gezerah
- rejects vidui missing slots
- rejects vidui all-empty (untagged disposal)
- rejects missing required fields
- accepts optional triggering_call + goral_outcome_id
vav_pipeline/test/l3_store_test.exs +9 :l3-tagged
record_disposal:
- inserts valid disposal
- rejects routing != eretz_gezerah
- rejects all-empty vidui
- rejects missing required fields
Counts:
- count_disposals == 0 initially
- count_disposals after several disposals
- count_disposals(since) filters by timestamp
Summary:
- disposal_summary returns categorical info but NOT items
(privacy boundary verification)
- categories_filled accurately reflects vidui shape
Tests: vav_pipeline 353 passed, 0 failures, 33 excluded (was
329/24; +15 visible azazel_ingest + 9 :l3-tagged disposal store).
korban_processor + azazel_processor unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6eb555a5cf
commit
2932fb7447
6 files changed
+997
-2
No files matched your search
@@ -0,0 +1,222 @@
|
|||||||
|
defmodule VavPipeline.AzazelIngest do
|
||||||
|
@moduledoc """
|
||||||
|
Cross-language bridge between the Python Azazel Processor and the
|
||||||
|
Elixir L3 layer.
|
||||||
|
|
||||||
|
The Python orchestrator (azazel_processor/src/azazel_processor/
|
||||||
|
azazel.py) serializes completed AzazelRecords to JSON files in a
|
||||||
|
pending-l3 directory. This GenServer watches that directory,
|
||||||
|
reads each file, calls L3.Store.record_disposal/1, and on success
|
||||||
|
shreds the source file.
|
||||||
|
|
||||||
|
The pattern matches Shorashim's other cross-language file bridges
|
||||||
|
(the manna provision file). Recoverable: if either the Python side
|
||||||
|
or this GenServer crashes mid-write, the file remains and is
|
||||||
|
re-processed on the next poll.
|
||||||
|
|
||||||
|
POLL-BASED, NOT FileSystem.event-BASED:
|
||||||
|
|
||||||
|
We do not bring the :file_system dependency into vav_pipeline.
|
||||||
|
Polling is simpler and adequate for the disposal cadence (the
|
||||||
|
Azazel channel is for exceptional content, not a high-throughput
|
||||||
|
stream). Default poll interval 5 seconds; configurable.
|
||||||
|
|
||||||
|
RETRY SEMANTICS:
|
||||||
|
|
||||||
|
If L3.Store.record_disposal/1 fails (validation error, DB
|
||||||
|
unavailable), the file is LEFT IN PLACE — it will be retried on
|
||||||
|
the next poll. The reasoning: L3 is append-only; a write that
|
||||||
|
failed for a transient reason should be retried, and a write
|
||||||
|
that failed for a permanent reason (schema mismatch) should
|
||||||
|
remain visible to operators rather than be silently destroyed.
|
||||||
|
|
||||||
|
Operator intervention path: if a file persists across many polls
|
||||||
|
with the same error, the operator inspects it directly in the
|
||||||
|
pending directory and decides whether to fix the schema, fix the
|
||||||
|
record, or move it to an error-archive subdirectory for analysis.
|
||||||
|
|
||||||
|
PROPERTY COVERAGE:
|
||||||
|
|
||||||
|
Property 9 (operator-internal half) — the L3 record exists. The
|
||||||
|
public-side endpoint (commit 8) reads from the same table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias VavPipeline.L3.Store
|
||||||
|
|
||||||
|
@default_poll_interval_ms 5_000
|
||||||
|
@default_pending_dir "/tmp/shorashim/azazel/pending_l3"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Client API
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Start the AzazelIngest GenServer. Options:
|
||||||
|
- :pending_dir — directory to watch (default
|
||||||
|
/tmp/shorashim/azazel/pending_l3)
|
||||||
|
- :poll_interval_ms — milliseconds between polls (default 5000)
|
||||||
|
- :name — process name (default __MODULE__)
|
||||||
|
- :recorder — function used to write the L3 record;
|
||||||
|
defaults to &Store.record_disposal/1.
|
||||||
|
Tests inject mocks here so they don't
|
||||||
|
need PostgreSQL.
|
||||||
|
"""
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
name = Keyword.get(opts, :name, __MODULE__)
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: name)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Run a single poll synchronously. Useful for tests and for
|
||||||
|
operator-triggered manual flushes.
|
||||||
|
"""
|
||||||
|
def poll_now(server \\ __MODULE__) do
|
||||||
|
GenServer.call(server, :poll_now)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Return the GenServer's current configuration and state."
|
||||||
|
def state(server \\ __MODULE__) do
|
||||||
|
GenServer.call(server, :state)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# GenServer
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(opts) do
|
||||||
|
state = %{
|
||||||
|
pending_dir: Keyword.get(opts, :pending_dir, @default_pending_dir),
|
||||||
|
poll_interval_ms: Keyword.get(opts, :poll_interval_ms, @default_poll_interval_ms),
|
||||||
|
recorder: Keyword.get(opts, :recorder, &Store.record_disposal/1),
|
||||||
|
polls_completed: 0,
|
||||||
|
records_ingested: 0,
|
||||||
|
errors: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
File.mkdir_p!(state.pending_dir)
|
||||||
|
schedule_poll(state.poll_interval_ms)
|
||||||
|
{:ok, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:poll_now, _from, state) do
|
||||||
|
new_state = do_poll(state)
|
||||||
|
{:reply, :ok, new_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:state, _from, state), do: {:reply, state, state}
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:poll, state) do
|
||||||
|
new_state = do_poll(state)
|
||||||
|
schedule_poll(new_state.poll_interval_ms)
|
||||||
|
{:noreply, new_state}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Internal
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
defp schedule_poll(interval_ms) do
|
||||||
|
Process.send_after(self(), :poll, interval_ms)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_poll(state) do
|
||||||
|
files = list_pending(state.pending_dir)
|
||||||
|
|
||||||
|
{ingested, errors} =
|
||||||
|
Enum.reduce(files, {0, 0}, fn path, {i, e} ->
|
||||||
|
case ingest_file(path, state.recorder) do
|
||||||
|
:ok -> {i + 1, e}
|
||||||
|
{:error, _reason} -> {i, e + 1}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
state
|
||||||
|
| polls_completed: state.polls_completed + 1,
|
||||||
|
records_ingested: state.records_ingested + ingested,
|
||||||
|
errors: state.errors + errors
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp list_pending(dir) do
|
||||||
|
case File.ls(dir) do
|
||||||
|
{:ok, names} ->
|
||||||
|
names
|
||||||
|
|> Enum.filter(&String.ends_with?(&1, ".json"))
|
||||||
|
|> Enum.sort()
|
||||||
|
|> Enum.map(&Path.join(dir, &1))
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ingest_file(path, recorder) do
|
||||||
|
with {:ok, body} <- File.read(path),
|
||||||
|
{:ok, raw} <- Jason.decode(body),
|
||||||
|
{:ok, attrs} <- normalize_record(raw),
|
||||||
|
{:ok, _record} <- recorder.(attrs) do
|
||||||
|
# Property 10 follow-through: shred the source file after
|
||||||
|
# successful L3 write. The disposal is now permanent oral
|
||||||
|
# tradition; the pending artifact must not linger.
|
||||||
|
File.rm(path)
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning(
|
||||||
|
"AzazelIngest: failed to ingest #{path} — #{inspect(reason)}. " <>
|
||||||
|
"File left in place for retry."
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Translate the Python-side JSON shape into Ecto-friendly attrs.
|
||||||
|
# The Python AzazelRecord.to_storage_dict() shape is:
|
||||||
|
# {
|
||||||
|
# "case_ref": str,
|
||||||
|
# "vidui": {avonot, peshaim, chatot, confessor, confessed_at},
|
||||||
|
# "routing": "eretz_gezerah",
|
||||||
|
# "authorized_by": str,
|
||||||
|
# "shaliach": str,
|
||||||
|
# "triggering_call": str | null,
|
||||||
|
# "goral_outcome_id": str | null,
|
||||||
|
# "disposed_at": iso8601 string
|
||||||
|
# }
|
||||||
|
# We pass `vidui` through as-is (JSONB column) and parse the
|
||||||
|
# disposed_at into a UTC DateTime.
|
||||||
|
defp normalize_record(raw) when is_map(raw) do
|
||||||
|
with {:ok, disposed_at} <- parse_iso8601(Map.get(raw, "disposed_at")) do
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
case_ref: Map.get(raw, "case_ref"),
|
||||||
|
vidui: Map.get(raw, "vidui"),
|
||||||
|
routing: Map.get(raw, "routing"),
|
||||||
|
authorized_by: Map.get(raw, "authorized_by"),
|
||||||
|
shaliach: Map.get(raw, "shaliach"),
|
||||||
|
triggering_call: Map.get(raw, "triggering_call"),
|
||||||
|
goral_outcome_id: Map.get(raw, "goral_outcome_id"),
|
||||||
|
disposed_at: disposed_at
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_record(_), do: {:error, :not_a_map}
|
||||||
|
|
||||||
|
defp parse_iso8601(nil), do: {:error, :missing_disposed_at}
|
||||||
|
|
||||||
|
defp parse_iso8601(s) when is_binary(s) do
|
||||||
|
case DateTime.from_iso8601(s) do
|
||||||
|
{:ok, dt, _offset} -> {:ok, DateTime.truncate(dt, :second)}
|
||||||
|
{:error, _} = err -> err
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
defmodule VavPipeline.L3.AzazelRecord do
|
||||||
|
@moduledoc """
|
||||||
|
L3 schema for Azazel disposal records — the gravestone for content
|
||||||
|
that went through the disposal channel.
|
||||||
|
|
||||||
|
Vayikra 16:8, 16:22 — the goat goes to eretz gezerah and does not
|
||||||
|
return; the RECORD of the disposal remains as permanent oral
|
||||||
|
tradition. Append-only: once a disposal is recorded, it is fixed.
|
||||||
|
|
||||||
|
THE PAYLOAD IS NOT HERE. Property 10 (hard termination) — the
|
||||||
|
AzazelRecord carries:
|
||||||
|
- case_ref opaque identifier of WHAT was disposed
|
||||||
|
- vidui operator-produced three-fold enumeration
|
||||||
|
- routing ALWAYS the literal "eretz_gezerah"
|
||||||
|
- authorized_by kohen_gadol identifier (property 7)
|
||||||
|
- shaliach ish itti identifier (property 4)
|
||||||
|
- triggering_call which subsystem invoked disposal (audit)
|
||||||
|
- goral_outcome_id if the goral was cast, its SHA-256 digest
|
||||||
|
(audit-recomputable per property 2)
|
||||||
|
- disposed_at orchestrator-clock timestamp
|
||||||
|
|
||||||
|
The vidui is stored as JSONB with the shape:
|
||||||
|
{
|
||||||
|
"avonot": [string, ...],
|
||||||
|
"peshaim": [string, ...],
|
||||||
|
"chatot": [string, ...],
|
||||||
|
"confessor": string,
|
||||||
|
"confessed_at": iso8601 string
|
||||||
|
}
|
||||||
|
|
||||||
|
Mirrors the Python AzazelRecord.to_storage_dict() (in
|
||||||
|
azazel_processor/src/azazel_processor/azazel_record.py). If either
|
||||||
|
side adds/removes a field, BOTH must move together — the cross-
|
||||||
|
language file watcher (VavPipeline.AzazelIngest) reads the Python
|
||||||
|
serialization and constructs an Ecto changeset from it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
schema "azazel_records" do
|
||||||
|
field :case_ref, :string
|
||||||
|
field :vidui, :map
|
||||||
|
field :routing, :string
|
||||||
|
field :authorized_by, :string
|
||||||
|
field :shaliach, :string
|
||||||
|
field :triggering_call, :string
|
||||||
|
field :goral_outcome_id, :string
|
||||||
|
field :disposed_at, :utc_datetime
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime, updated_at: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
@required_fields [
|
||||||
|
:case_ref,
|
||||||
|
:vidui,
|
||||||
|
:routing,
|
||||||
|
:authorized_by,
|
||||||
|
:shaliach,
|
||||||
|
:disposed_at
|
||||||
|
]
|
||||||
|
|
||||||
|
@optional_fields [:triggering_call, :goral_outcome_id]
|
||||||
|
|
||||||
|
def changeset(record, attrs) do
|
||||||
|
record
|
||||||
|
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||||
|
|> validate_required(@required_fields)
|
||||||
|
|> validate_inclusion(:routing, ["eretz_gezerah"],
|
||||||
|
message:
|
||||||
|
"routing must be the canonical opaque value 'eretz_gezerah'. " <>
|
||||||
|
"Property 12 (unmanaged destination) — there are no other " <>
|
||||||
|
"legitimate routing values."
|
||||||
|
)
|
||||||
|
|> validate_vidui_shape()
|
||||||
|
end
|
||||||
|
|
||||||
|
# The vidui must have all three categorical slots present (lists
|
||||||
|
# may be empty individually, but the absence of a slot is not OK).
|
||||||
|
# Plus a confessor and confessed_at. Mirrors the Python Vidui's
|
||||||
|
# __post_init__ validation.
|
||||||
|
defp validate_vidui_shape(changeset) do
|
||||||
|
case get_change(changeset, :vidui) do
|
||||||
|
nil ->
|
||||||
|
changeset
|
||||||
|
|
||||||
|
vidui when is_map(vidui) ->
|
||||||
|
required_slots = ~w(avonot peshaim chatot confessor confessed_at)
|
||||||
|
missing = Enum.reject(required_slots, &Map.has_key?(vidui, &1))
|
||||||
|
|
||||||
|
cond do
|
||||||
|
missing != [] ->
|
||||||
|
add_error(
|
||||||
|
changeset,
|
||||||
|
:vidui,
|
||||||
|
"missing required slot(s): #{Enum.join(missing, ", ")}"
|
||||||
|
)
|
||||||
|
|
||||||
|
all_three_categories_empty?(vidui) ->
|
||||||
|
add_error(
|
||||||
|
changeset,
|
||||||
|
:vidui,
|
||||||
|
"all three categories empty — untagged disposal " <>
|
||||||
|
"(property 3 violation)"
|
||||||
|
)
|
||||||
|
|
||||||
|
true ->
|
||||||
|
changeset
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
add_error(changeset, :vidui, "must be a map")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp all_three_categories_empty?(vidui) do
|
||||||
|
Enum.all?(["avonot", "peshaim", "chatot"], fn slot ->
|
||||||
|
case Map.get(vidui, slot) do
|
||||||
|
[] -> true
|
||||||
|
nil -> true
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -18,7 +18,14 @@ defmodule VavPipeline.L3.Store do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
alias VavPipeline.Repo
|
alias VavPipeline.Repo
|
||||||
alias VavPipeline.L3.{CouplingTrace, Interpretation, MinorityOpinion, TeikuRecord}
|
|
||||||
|
alias VavPipeline.L3.{
|
||||||
|
AzazelRecord,
|
||||||
|
CouplingTrace,
|
||||||
|
Interpretation,
|
||||||
|
MinorityOpinion,
|
||||||
|
TeikuRecord
|
||||||
|
}
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
@@ -79,6 +86,30 @@ defmodule VavPipeline.L3.Store do
|
|||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Record an Azazel disposal to L3.
|
||||||
|
|
||||||
|
Accepts a map with the fields from the AzazelRecord schema —
|
||||||
|
case_ref, vidui (map with avonot/peshaim/chatot/confessor/
|
||||||
|
confessed_at), routing (must be "eretz_gezerah"), authorized_by,
|
||||||
|
shaliach, optionally triggering_call and goral_outcome_id, and
|
||||||
|
disposed_at (UTC datetime).
|
||||||
|
|
||||||
|
Returns {:ok, record} or {:error, changeset}.
|
||||||
|
|
||||||
|
Pass 4 / Finding F, commit 7. The Python Azazel orchestrator
|
||||||
|
serializes AzazelRecord.to_storage_dict() to a JSON file under
|
||||||
|
the pending-l3 directory; VavPipeline.AzazelIngest reads each
|
||||||
|
file, calls this function, and deletes the source file on
|
||||||
|
success. The cross-language bridge for property 9 (operator-
|
||||||
|
internal observability).
|
||||||
|
"""
|
||||||
|
def record_disposal(attrs) when is_map(attrs) do
|
||||||
|
%AzazelRecord{}
|
||||||
|
|> AzazelRecord.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Resolve a teiku — update resolved_at and resolution.
|
Resolve a teiku — update resolved_at and resolution.
|
||||||
|
|
||||||
@@ -149,4 +180,74 @@ defmodule VavPipeline.L3.Store do
|
|||||||
def count_minority_opinions do
|
def count_minority_opinions do
|
||||||
Repo.aggregate(MinorityOpinion, :count, :id)
|
Repo.aggregate(MinorityOpinion, :count, :id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Count Azazel disposals since a given timestamp (or all-time when nil).
|
||||||
|
Used by the public observability endpoint (commit 8) — counts only,
|
||||||
|
no payloads, per property 9's privacy boundary.
|
||||||
|
"""
|
||||||
|
def count_disposals(since \\ nil)
|
||||||
|
|
||||||
|
def count_disposals(nil) do
|
||||||
|
Repo.aggregate(AzazelRecord, :count, :id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def count_disposals(%DateTime{} = since) do
|
||||||
|
AzazelRecord
|
||||||
|
|> where([r], r.disposed_at >= ^since)
|
||||||
|
|> Repo.aggregate(:count, :id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Categorical summary of Azazel disposals since a given timestamp.
|
||||||
|
|
||||||
|
Returns a list of %{shaliach, count, vidui_categories_filled} maps.
|
||||||
|
vidui_categories_filled is which of avonot/peshaim/chatot had any
|
||||||
|
items, NOT the items themselves — property 9's public-side privacy
|
||||||
|
boundary. The detailed vidui is operator-internal (queryable via
|
||||||
|
/kodesh/* endpoints, commit 8).
|
||||||
|
"""
|
||||||
|
def disposal_summary(since \\ nil) do
|
||||||
|
query =
|
||||||
|
AzazelRecord
|
||||||
|
|> select([r], %{
|
||||||
|
case_ref: r.case_ref,
|
||||||
|
shaliach: r.shaliach,
|
||||||
|
vidui: r.vidui,
|
||||||
|
triggering_call: r.triggering_call,
|
||||||
|
disposed_at: r.disposed_at
|
||||||
|
})
|
||||||
|
|> order_by([r], desc: r.disposed_at)
|
||||||
|
|
||||||
|
query =
|
||||||
|
case since do
|
||||||
|
nil -> query
|
||||||
|
%DateTime{} = ts -> where(query, [r], r.disposed_at >= ^ts)
|
||||||
|
end
|
||||||
|
|
||||||
|
query
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.map(fn row ->
|
||||||
|
%{
|
||||||
|
case_ref: row.case_ref,
|
||||||
|
shaliach: row.shaliach,
|
||||||
|
triggering_call: row.triggering_call,
|
||||||
|
disposed_at: row.disposed_at,
|
||||||
|
# Categorical only — what slots were filled, NOT the items
|
||||||
|
categories_filled: vidui_categories_filled(row.vidui)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp vidui_categories_filled(vidui) when is_map(vidui) do
|
||||||
|
Enum.reduce(["avonot", "peshaim", "chatot"], [], fn slot, acc ->
|
||||||
|
case Map.get(vidui, slot) do
|
||||||
|
list when is_list(list) and list != [] -> [slot | acc]
|
||||||
|
_ -> acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> Enum.reverse()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp vidui_categories_filled(_), do: []
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
defmodule VavPipeline.Repo.Migrations.CreateAzazelRecords do
|
||||||
|
@moduledoc """
|
||||||
|
L3 — Azazel disposal records. Append-only, parallel to interpretations.
|
||||||
|
|
||||||
|
Vayikra 16: the disposed thing goes to eretz gezerah and does not
|
||||||
|
return; the RECORD of the disposal remains as permanent oral
|
||||||
|
tradition. The two-goat architecture: this table parallels
|
||||||
|
interpretations the way the Azazel Processor parallels the Korban
|
||||||
|
Processor.
|
||||||
|
|
||||||
|
PROPERTY 9 — observable disposal:
|
||||||
|
The public fact of disposal is part of the implementation, not
|
||||||
|
incidental to it. This table is the operator-internal half;
|
||||||
|
the /peah/azazel_log endpoint (commit 8) is the public half.
|
||||||
|
|
||||||
|
PROPERTY 10 — hard termination:
|
||||||
|
The DISPOSED PAYLOAD IS NOT IN THIS TABLE. Only the vidui (what
|
||||||
|
was named) and the routing decision (always eretz_gezerah). A
|
||||||
|
schema CHECK constraint enforces routing = 'eretz_gezerah' so
|
||||||
|
a future migration cannot accidentally introduce a
|
||||||
|
destination-handle column.
|
||||||
|
|
||||||
|
Append-only enforcement matches interpretations: REVOKE
|
||||||
|
UPDATE/DELETE on the shorashim_app role.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
create table(:azazel_records) do
|
||||||
|
# The opaque case_ref — content-addressable identifier of what
|
||||||
|
# was disposed (NOT the content itself).
|
||||||
|
add :case_ref, :text, null: false
|
||||||
|
|
||||||
|
# The vidui — operator-produced enumerative commit, three-fold
|
||||||
|
# (avonot/peshaim/chatot) per Vayikra 16:21. Stored as JSONB
|
||||||
|
# so the structure is queryable but the content is not parsed
|
||||||
|
# by Postgres-side logic.
|
||||||
|
add :vidui, :jsonb, null: false
|
||||||
|
|
||||||
|
# The routing — ALWAYS the literal string "eretz_gezerah". The
|
||||||
|
# CHECK constraint below makes this structural.
|
||||||
|
add :routing, :text, null: false
|
||||||
|
|
||||||
|
# Property 7 — authorized routing
|
||||||
|
add :authorized_by, :text, null: false # kohen_gadol identifier
|
||||||
|
add :shaliach, :text, null: false # ish itti identifier
|
||||||
|
|
||||||
|
# Audit metadata
|
||||||
|
add :triggering_call, :text # which subsystem invoked
|
||||||
|
add :goral_outcome_id, :text # if goral was cast, the
|
||||||
|
# SHA-256 outcome digest
|
||||||
|
# (audit-recomputable)
|
||||||
|
|
||||||
|
# When the disposal happened (per the orchestrator's clock, not
|
||||||
|
# the L3 write clock — those can differ if the file watcher
|
||||||
|
# picks up a backlog).
|
||||||
|
add :disposed_at, :utc_datetime, null: false
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime, updated_at: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Property 12 / property 7: routing must be the canonical opaque
|
||||||
|
# value. A future migration that wants to add a destination
|
||||||
|
# handle will fail this check; the operator must explicitly
|
||||||
|
# justify removing the constraint, which is the right friction.
|
||||||
|
create constraint(
|
||||||
|
:azazel_records,
|
||||||
|
:routing_must_be_eretz_gezerah,
|
||||||
|
check: "routing = 'eretz_gezerah'"
|
||||||
|
)
|
||||||
|
|
||||||
|
create index(:azazel_records, [:case_ref])
|
||||||
|
create index(:azazel_records, [:disposed_at])
|
||||||
|
# Index on shaliach for operator queries ("what did this handler
|
||||||
|
# dispose of?") — useful for tevilah audit and for property 4
|
||||||
|
# accountability.
|
||||||
|
create index(:azazel_records, [:shaliach])
|
||||||
|
|
||||||
|
# Append-only enforcement — same pattern as interpretations.
|
||||||
|
execute """
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'shorashim_app') THEN
|
||||||
|
REVOKE UPDATE, DELETE ON azazel_records FROM shorashim_app;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop table(:azazel_records)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
defmodule VavPipeline.AzazelIngestTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for the cross-language Azazel ingest GenServer (Pass 4 /
|
||||||
|
Finding F, commit 7). These tests verify the file-watcher logic
|
||||||
|
and the AzazelRecord changeset validation; they do NOT require
|
||||||
|
PostgreSQL because the recorder is injected as a mock.
|
||||||
|
|
||||||
|
L3-side tests (the actual record_disposal write) live in
|
||||||
|
l3_store_test.exs tagged :l3.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias VavPipeline.AzazelIngest
|
||||||
|
alias VavPipeline.L3.AzazelRecord
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Test helpers
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
setup do
|
||||||
|
# Per-test pending directory so tests don't share state.
|
||||||
|
dir =
|
||||||
|
System.tmp_dir!()
|
||||||
|
|> Path.join("azazel-ingest-test-#{:erlang.unique_integer([:positive])}")
|
||||||
|
|
||||||
|
File.mkdir_p!(dir)
|
||||||
|
on_exit(fn -> File.rm_rf!(dir) end)
|
||||||
|
%{pending_dir: dir}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp valid_record_json(case_ref \\ "case-001") do
|
||||||
|
%{
|
||||||
|
"case_ref" => case_ref,
|
||||||
|
"vidui" => %{
|
||||||
|
"avonot" => [],
|
||||||
|
"peshaim" => [],
|
||||||
|
"chatot" => ["empty-vital offering: test"],
|
||||||
|
"confessor" => "kohen-gadol",
|
||||||
|
"confessed_at" => "2026-04-28T12:00:00Z"
|
||||||
|
},
|
||||||
|
"routing" => "eretz_gezerah",
|
||||||
|
"authorized_by" => "kohen-gadol",
|
||||||
|
"shaliach" => "aharon",
|
||||||
|
"triggering_call" => "external_offering",
|
||||||
|
"goral_outcome_id" => nil,
|
||||||
|
"disposed_at" => "2026-04-28T12:00:01Z"
|
||||||
|
}
|
||||||
|
|> Jason.encode!()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp drop_file(dir, name, body) do
|
||||||
|
File.write!(Path.join(dir, name), body)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_ingest(dir, opts \\ []) do
|
||||||
|
name = :"ingest_#{:erlang.unique_integer([:positive])}"
|
||||||
|
|
||||||
|
captured = :ets.new(:captured, [:public, :bag])
|
||||||
|
|
||||||
|
recorder = fn attrs ->
|
||||||
|
:ets.insert(captured, {:record, attrs})
|
||||||
|
{:ok, attrs}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, pid} =
|
||||||
|
AzazelIngest.start_link(
|
||||||
|
Keyword.merge(
|
||||||
|
[
|
||||||
|
name: name,
|
||||||
|
pending_dir: dir,
|
||||||
|
poll_interval_ms: 60_000, # don't auto-poll during tests
|
||||||
|
recorder: recorder
|
||||||
|
],
|
||||||
|
opts
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
|
||||||
|
%{server: name, captured: captured, recorder: recorder}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Happy path
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
test "poll_now ingests a valid file and shreds it", %{pending_dir: dir} do
|
||||||
|
drop_file(dir, "case-001.json", valid_record_json())
|
||||||
|
%{server: server, captured: captured} = start_ingest(dir)
|
||||||
|
|
||||||
|
AzazelIngest.poll_now(server)
|
||||||
|
|
||||||
|
# File was shredded
|
||||||
|
refute File.exists?(Path.join(dir, "case-001.json"))
|
||||||
|
|
||||||
|
# Record was passed to the recorder
|
||||||
|
assert [{:record, attrs}] = :ets.tab2list(captured)
|
||||||
|
assert attrs.case_ref == "case-001"
|
||||||
|
assert attrs.routing == "eretz_gezerah"
|
||||||
|
assert attrs.authorized_by == "kohen-gadol"
|
||||||
|
assert attrs.shaliach == "aharon"
|
||||||
|
assert %DateTime{} = attrs.disposed_at
|
||||||
|
end
|
||||||
|
|
||||||
|
test "poll_now ingests multiple files in sorted order", %{pending_dir: dir} do
|
||||||
|
drop_file(dir, "case-002.json", valid_record_json("case-002"))
|
||||||
|
drop_file(dir, "case-001.json", valid_record_json("case-001"))
|
||||||
|
drop_file(dir, "case-003.json", valid_record_json("case-003"))
|
||||||
|
|
||||||
|
%{server: server, captured: captured} = start_ingest(dir)
|
||||||
|
AzazelIngest.poll_now(server)
|
||||||
|
|
||||||
|
refs =
|
||||||
|
:ets.tab2list(captured)
|
||||||
|
|> Enum.map(fn {:record, attrs} -> attrs.case_ref end)
|
||||||
|
|
||||||
|
assert refs == ["case-001", "case-002", "case-003"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ingest preserves the vidui as a map (jsonb column)", %{pending_dir: dir} do
|
||||||
|
drop_file(dir, "case-vidui.json", valid_record_json())
|
||||||
|
%{server: server, captured: captured} = start_ingest(dir)
|
||||||
|
|
||||||
|
AzazelIngest.poll_now(server)
|
||||||
|
|
||||||
|
[{:record, attrs}] = :ets.tab2list(captured)
|
||||||
|
assert is_map(attrs.vidui)
|
||||||
|
assert attrs.vidui["chatot"] == ["empty-vital offering: test"]
|
||||||
|
assert attrs.vidui["confessor"] == "kohen-gadol"
|
||||||
|
end
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Retry semantics — failed records left in place
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
test "file is left in place when recorder returns error", %{pending_dir: dir} do
|
||||||
|
drop_file(dir, "case-fail.json", valid_record_json())
|
||||||
|
|
||||||
|
name = :"ingest_fail_#{:erlang.unique_integer([:positive])}"
|
||||||
|
|
||||||
|
failing_recorder = fn _ -> {:error, :db_unavailable} end
|
||||||
|
|
||||||
|
{:ok, pid} =
|
||||||
|
AzazelIngest.start_link(
|
||||||
|
name: name,
|
||||||
|
pending_dir: dir,
|
||||||
|
poll_interval_ms: 60_000,
|
||||||
|
recorder: failing_recorder
|
||||||
|
)
|
||||||
|
|
||||||
|
on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end)
|
||||||
|
|
||||||
|
AzazelIngest.poll_now(name)
|
||||||
|
|
||||||
|
# File still present for retry
|
||||||
|
assert File.exists?(Path.join(dir, "case-fail.json"))
|
||||||
|
state = AzazelIngest.state(name)
|
||||||
|
assert state.errors == 1
|
||||||
|
assert state.records_ingested == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "file is left in place when JSON is malformed", %{pending_dir: dir} do
|
||||||
|
drop_file(dir, "case-malformed.json", "not-json{")
|
||||||
|
%{server: server} = start_ingest(dir)
|
||||||
|
|
||||||
|
AzazelIngest.poll_now(server)
|
||||||
|
|
||||||
|
# File still present (operator can investigate)
|
||||||
|
assert File.exists?(Path.join(dir, "case-malformed.json"))
|
||||||
|
state = AzazelIngest.state(server)
|
||||||
|
assert state.errors == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "file is left in place when disposed_at is missing", %{pending_dir: dir} do
|
||||||
|
raw =
|
||||||
|
%{"case_ref" => "case-no-ts", "vidui" => %{}, "routing" => "eretz_gezerah"}
|
||||||
|
|> Jason.encode!()
|
||||||
|
|
||||||
|
drop_file(dir, "case-no-ts.json", raw)
|
||||||
|
%{server: server} = start_ingest(dir)
|
||||||
|
|
||||||
|
AzazelIngest.poll_now(server)
|
||||||
|
|
||||||
|
assert File.exists?(Path.join(dir, "case-no-ts.json"))
|
||||||
|
end
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# State accounting
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
test "state tracks polls_completed, records_ingested, errors", %{pending_dir: dir} do
|
||||||
|
drop_file(dir, "case-001.json", valid_record_json("case-001"))
|
||||||
|
drop_file(dir, "case-002.json", valid_record_json("case-002"))
|
||||||
|
drop_file(dir, "case-bad.json", "garbage")
|
||||||
|
|
||||||
|
%{server: server} = start_ingest(dir)
|
||||||
|
|
||||||
|
AzazelIngest.poll_now(server)
|
||||||
|
|
||||||
|
state = AzazelIngest.state(server)
|
||||||
|
assert state.polls_completed == 1
|
||||||
|
assert state.records_ingested == 2
|
||||||
|
assert state.errors == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "state survives multiple polls", %{pending_dir: dir} do
|
||||||
|
%{server: server} = start_ingest(dir)
|
||||||
|
AzazelIngest.poll_now(server)
|
||||||
|
AzazelIngest.poll_now(server)
|
||||||
|
AzazelIngest.poll_now(server)
|
||||||
|
|
||||||
|
state = AzazelIngest.state(server)
|
||||||
|
assert state.polls_completed == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-json files are ignored", %{pending_dir: dir} do
|
||||||
|
File.write!(Path.join(dir, "README.txt"), "not a record")
|
||||||
|
drop_file(dir, "case-001.json", valid_record_json())
|
||||||
|
|
||||||
|
%{server: server, captured: captured} = start_ingest(dir)
|
||||||
|
AzazelIngest.poll_now(server)
|
||||||
|
|
||||||
|
# JSON file processed; README left alone
|
||||||
|
refute File.exists?(Path.join(dir, "case-001.json"))
|
||||||
|
assert File.exists?(Path.join(dir, "README.txt"))
|
||||||
|
assert length(:ets.tab2list(captured)) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Changeset validation (no DB)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
describe "AzazelRecord.changeset/2" do
|
||||||
|
test "valid attrs produce a valid changeset" do
|
||||||
|
attrs = %{
|
||||||
|
case_ref: "case-cs-001",
|
||||||
|
vidui: %{
|
||||||
|
"avonot" => [],
|
||||||
|
"peshaim" => [],
|
||||||
|
"chatot" => ["something"],
|
||||||
|
"confessor" => "kohen",
|
||||||
|
"confessed_at" => "2026-04-28T12:00:00Z"
|
||||||
|
},
|
||||||
|
routing: "eretz_gezerah",
|
||||||
|
authorized_by: "kohen-gadol",
|
||||||
|
shaliach: "aharon",
|
||||||
|
disposed_at: ~U[2026-04-28 12:00:01Z]
|
||||||
|
}
|
||||||
|
|
||||||
|
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
|
||||||
|
assert cs.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects routing other than eretz_gezerah" do
|
||||||
|
attrs = base_attrs(%{routing: "void"})
|
||||||
|
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
|
||||||
|
refute cs.valid?
|
||||||
|
assert {:routing, _} = List.keyfind(cs.errors, :routing, 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects vidui missing avonot slot" do
|
||||||
|
attrs =
|
||||||
|
base_attrs(%{
|
||||||
|
vidui: %{
|
||||||
|
"peshaim" => [],
|
||||||
|
"chatot" => ["x"],
|
||||||
|
"confessor" => "kohen",
|
||||||
|
"confessed_at" => "2026-04-28T12:00:00Z"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
|
||||||
|
refute cs.valid?
|
||||||
|
assert {:vidui, {msg, _}} = List.keyfind(cs.errors, :vidui, 0)
|
||||||
|
assert msg =~ "avonot"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects vidui with all three categories empty" do
|
||||||
|
attrs =
|
||||||
|
base_attrs(%{
|
||||||
|
vidui: %{
|
||||||
|
"avonot" => [],
|
||||||
|
"peshaim" => [],
|
||||||
|
"chatot" => [],
|
||||||
|
"confessor" => "kohen",
|
||||||
|
"confessed_at" => "2026-04-28T12:00:00Z"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
|
||||||
|
refute cs.valid?
|
||||||
|
assert {:vidui, {msg, _}} = List.keyfind(cs.errors, :vidui, 0)
|
||||||
|
assert msg =~ "untagged"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects missing required fields" do
|
||||||
|
cs = AzazelRecord.changeset(%AzazelRecord{}, %{})
|
||||||
|
refute cs.valid?
|
||||||
|
|
||||||
|
missing = Enum.map(cs.errors, fn {k, _} -> k end)
|
||||||
|
for required <- [:case_ref, :vidui, :routing, :authorized_by, :shaliach, :disposed_at] do
|
||||||
|
assert required in missing, "expected #{required} to be missing"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts optional triggering_call and goral_outcome_id" do
|
||||||
|
attrs = base_attrs(%{
|
||||||
|
triggering_call: "external_offering",
|
||||||
|
goral_outcome_id: String.duplicate("a", 64)
|
||||||
|
})
|
||||||
|
|
||||||
|
cs = AzazelRecord.changeset(%AzazelRecord{}, attrs)
|
||||||
|
assert cs.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
defp base_attrs(overrides \\ %{}) do
|
||||||
|
Map.merge(
|
||||||
|
%{
|
||||||
|
case_ref: "case-base-001",
|
||||||
|
vidui: %{
|
||||||
|
"avonot" => [],
|
||||||
|
"peshaim" => [],
|
||||||
|
"chatot" => ["something"],
|
||||||
|
"confessor" => "kohen",
|
||||||
|
"confessed_at" => "2026-04-28T12:00:00Z"
|
||||||
|
},
|
||||||
|
routing: "eretz_gezerah",
|
||||||
|
authorized_by: "kohen-gadol",
|
||||||
|
shaliach: "aharon",
|
||||||
|
disposed_at: ~U[2026-04-28 12:00:01Z]
|
||||||
|
},
|
||||||
|
overrides
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,7 +11,7 @@ defmodule VavPipeline.L3.StoreTest do
|
|||||||
@moduletag :l3
|
@moduletag :l3
|
||||||
|
|
||||||
alias VavPipeline.L3.Store
|
alias VavPipeline.L3.Store
|
||||||
alias VavPipeline.L3.{Interpretation, MinorityOpinion, TeikuRecord}
|
alias VavPipeline.L3.{AzazelRecord, Interpretation, MinorityOpinion, TeikuRecord}
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
:ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo)
|
:ok = Ecto.Adapters.SQL.Sandbox.checkout(VavPipeline.Repo)
|
||||||
@@ -293,6 +293,122 @@ defmodule VavPipeline.L3.StoreTest do
|
|||||||
assert "rejected" in statuses
|
assert "rejected" in statuses
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# === AZAZEL DISPOSAL RECORDS (Pass 4 / Finding F, commit 7) ===
|
||||||
|
# These tests exercise the L3.Store.record_disposal/1 path against
|
||||||
|
# an actual database. Property 9 (operator-internal half) — the
|
||||||
|
# disposal becomes permanent oral tradition.
|
||||||
|
|
||||||
|
@valid_disposal %{
|
||||||
|
case_ref: "azazel_case_001",
|
||||||
|
vidui: %{
|
||||||
|
"avonot" => [],
|
||||||
|
"peshaim" => [],
|
||||||
|
"chatot" => ["empty-vital offering: claude response on bereishit 1:1"],
|
||||||
|
"confessor" => "kohen-gadol-aharon",
|
||||||
|
"confessed_at" => "2026-04-28T12:00:00Z"
|
||||||
|
},
|
||||||
|
routing: "eretz_gezerah",
|
||||||
|
authorized_by: "kohen-gadol-aharon",
|
||||||
|
shaliach: "ish-itti-001",
|
||||||
|
triggering_call: "external_offering.consult_external_model",
|
||||||
|
disposed_at: ~U[2026-04-28 12:00:01Z]
|
||||||
|
}
|
||||||
|
|
||||||
|
test "record_disposal inserts a valid disposal" do
|
||||||
|
assert {:ok, %AzazelRecord{} = record} = Store.record_disposal(@valid_disposal)
|
||||||
|
assert record.id != nil
|
||||||
|
assert record.case_ref == "azazel_case_001"
|
||||||
|
assert record.routing == "eretz_gezerah"
|
||||||
|
assert record.shaliach == "ish-itti-001"
|
||||||
|
assert record.authorized_by == "kohen-gadol-aharon"
|
||||||
|
assert is_map(record.vidui)
|
||||||
|
assert record.vidui["confessor"] == "kohen-gadol-aharon"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "record_disposal rejects routing other than eretz_gezerah" do
|
||||||
|
attrs = Map.put(@valid_disposal, :routing, "void")
|
||||||
|
assert {:error, changeset} = Store.record_disposal(attrs)
|
||||||
|
assert %{routing: _} = errors_on(changeset)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "record_disposal rejects vidui with all categories empty" do
|
||||||
|
attrs =
|
||||||
|
Map.put(@valid_disposal, :vidui, %{
|
||||||
|
"avonot" => [],
|
||||||
|
"peshaim" => [],
|
||||||
|
"chatot" => [],
|
||||||
|
"confessor" => "kohen",
|
||||||
|
"confessed_at" => "2026-04-28T12:00:00Z"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:error, changeset} = Store.record_disposal(attrs)
|
||||||
|
errors = errors_on(changeset)
|
||||||
|
assert errors[:vidui] != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "record_disposal rejects missing required fields" do
|
||||||
|
assert {:error, changeset} = Store.record_disposal(%{routing: "eretz_gezerah"})
|
||||||
|
refute changeset.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "count_disposals returns 0 when none recorded" do
|
||||||
|
assert Store.count_disposals() == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "count_disposals returns total after several disposals" do
|
||||||
|
{:ok, _} = Store.record_disposal(@valid_disposal)
|
||||||
|
{:ok, _} = Store.record_disposal(Map.put(@valid_disposal, :case_ref, "azazel_case_002"))
|
||||||
|
{:ok, _} = Store.record_disposal(Map.put(@valid_disposal, :case_ref, "azazel_case_003"))
|
||||||
|
assert Store.count_disposals() == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "count_disposals(since) filters by disposed_at" do
|
||||||
|
early =
|
||||||
|
@valid_disposal
|
||||||
|
|> Map.put(:case_ref, "early")
|
||||||
|
|> Map.put(:disposed_at, ~U[2026-04-01 00:00:00Z])
|
||||||
|
|
||||||
|
late =
|
||||||
|
@valid_disposal
|
||||||
|
|> Map.put(:case_ref, "late")
|
||||||
|
|> Map.put(:disposed_at, ~U[2026-04-30 00:00:00Z])
|
||||||
|
|
||||||
|
{:ok, _} = Store.record_disposal(early)
|
||||||
|
{:ok, _} = Store.record_disposal(late)
|
||||||
|
|
||||||
|
assert Store.count_disposals(~U[2026-04-15 00:00:00Z]) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "disposal_summary returns categorical info but NOT vidui items" do
|
||||||
|
{:ok, _} = Store.record_disposal(@valid_disposal)
|
||||||
|
|
||||||
|
[summary] = Store.disposal_summary()
|
||||||
|
|
||||||
|
# Has the expected structural fields
|
||||||
|
assert summary.case_ref == "azazel_case_001"
|
||||||
|
assert summary.shaliach == "ish-itti-001"
|
||||||
|
assert summary.triggering_call == "external_offering.consult_external_model"
|
||||||
|
assert "chatot" in summary.categories_filled
|
||||||
|
|
||||||
|
# Privacy boundary (property 9): the actual vidui items are NOT
|
||||||
|
# in the summary. The endpoint exposes which categories were
|
||||||
|
# filled, not what was named.
|
||||||
|
assert :items not in Map.keys(summary)
|
||||||
|
refute Enum.any?(Map.values(summary), fn v ->
|
||||||
|
v == "empty-vital offering: claude response on bereishit 1:1"
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "disposal_summary categories_filled accurately reflects vidui" do
|
||||||
|
avonot_only = put_in(@valid_disposal, [:vidui, "avonot"], ["halt avodah_zarah: x"])
|
||||||
|
avonot_only = put_in(avonot_only, [:vidui, "chatot"], [])
|
||||||
|
avonot_only = Map.put(avonot_only, :case_ref, "avonot-only")
|
||||||
|
{:ok, _} = Store.record_disposal(avonot_only)
|
||||||
|
|
||||||
|
[summary] = Store.disposal_summary()
|
||||||
|
assert summary.categories_filled == ["avonot"]
|
||||||
|
end
|
||||||
|
|
||||||
# Helper to extract error messages from changeset
|
# Helper to extract error messages from changeset
|
||||||
defp errors_on(changeset) do
|
defp errors_on(changeset) do
|
||||||
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
||||||
|
|||||||
Reference in new issue
Block a user