the ledger: a caller's own bedikot, read back and signed

The examination is the product; the record of it is what a caller can
carry away. Every bedikah was already engraved in L3 — output hash and
length, source digests, coupling trace, claims, violations, verdict,
never the text — and no caller could get theirs back.

- bedikot.key_id (migration): a SHA-256 digest of the courtyard key that
  brought the offering, never the key; threaded as :key_id through
  Bedikah.examine from every site that goes through the gate (courtyard
  bedikah, pair, the gate's pass and stream). Unique index on
  bedikah_ref.
- VavPipeline.Ledger: entry (the examination's fields, none of the
  database's, not the owner's), canonical JSON (keys sorted at every
  level, no whitespace, UTF-8), Ed25519 sign/verify, public key, render.
  Seed from BIKORET_LEDGER_SIGNING_KEY (base64 of 32 bytes) in
  shorashim-secrets; absent → unsigned and says so.
- GET /courtyard/ledger?limit&before — this key's bedikot, newest first,
  each rendered and signed; GET /courtyard/bedikah/<ref> — one, 404 for
  another key's or none alike; GET /peah/ledger_key — the public key.
- Tamid §1.1 "The ledger" (Doorpost pin moved → 02ddf313…), KOHEN
  "Issuing a key" (the seed, once), secrets.yaml template.

Tests: ledger_test (digest, entry, canonical, sign/verify, unsigned
rendering, signed rendering); ledger_l3_test (own records listed newest
first and only own; 404 alike for another's and none; an examination
engraved under the key reads back signed and verifies against
/peah/ledger_key). Elixir 642/0; L3 13/0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Scott KarpandClaude Fable 5.1 committed 2026-10-21 18:45:47 +00:00
1 parent e6c3702a22
commit d6cf9355fd
13 files changed
+542 -13

No files matched your search

+9 -6
View File
@@ -133,6 +133,7 @@ defmodule VavPipeline.Bedikah do
sources = Keyword.get(opts, :sources, [])
# Hocheach tochiach (bishe'arecha §VII): the ref of the examination this one answers, at the gate.
rebukes = Keyword.get(opts, :rebukes)
key_id = Keyword.get(opts, :key_id)
examiner = Keyword.get(opts, :examiner, &KorbanProcessorSocket.examine/4)
halt_checker = Keyword.get(opts, :halt_checker, &TalmudicEngineSocket.halt_check_term/1)
@@ -141,12 +142,12 @@ defmodule VavPipeline.Bedikah do
# D'var yom b'yomo: an examination is a cycle's work and eats a unit.
case Manna.gather_for_cycle() do
:ok -> run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker)
:ok -> run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker, key_id)
{:waiting, reason} -> {:waiting, reason}
end
end
defp run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker) do
defp run(output, provenance, query, sources, rebukes, examiner, halt_checker, interpretation_checker, key_id) do
bedikah_ref = "bedikah-" <> (:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower))
with {:ok, examination} <- examiner.(output, provenance, query, sources) do
@@ -173,7 +174,7 @@ defmodule VavPipeline.Bedikah do
}
}
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, [], checks, [], :empty)
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, [], checks, [], :empty, key_id)
else
trace = coupling_trace(examination)
@@ -188,7 +189,7 @@ defmodule VavPipeline.Bedikah do
checks = Map.merge(%{authority: authority, grounding: grounding, actions: actions, frames: frames}, structure)
violations = prior ++ frames.violations
verdict = if violations == [], do: :tov, else: :lo_tov
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict)
finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict, key_id)
else
{:error, :talmudic, reason} -> {:error, :talmudic_unavailable, reason}
end
@@ -554,7 +555,7 @@ defmodule VavPipeline.Bedikah do
end
end
defp finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict) do
defp finish(bedikah_ref, output, provenance, query, sources, rebukes, examination, trace, checks, violations, verdict, key_id) do
result = %{
bedikah_ref: bedikah_ref,
provenance: provenance,
@@ -566,7 +567,8 @@ defmodule VavPipeline.Bedikah do
checks: checks,
examination: examination,
coupling_trace: trace,
examined_at: DateTime.utc_now()
examined_at: DateTime.utc_now(),
key_id: key_id
}
record(result, output)
@@ -601,6 +603,7 @@ defmodule VavPipeline.Bedikah do
rebukes: result.rebukes,
coupling_trace: result.coupling_trace,
violations: result.violations,
key_id: result.key_id,
verdict: Atom.to_string(result.verdict),
examined_at: result.examined_at
}
@@ -215,6 +215,21 @@ defmodule VavPipeline.BigdeiKodesh.Router do
through_the_gate(conn, :courtyard, &process_pair/1)
end
# === THE LEDGER — a caller's own bedikot, read back and signed ===
#
# Every examination is engraved in L3 under a digest of the key that
# brought it. The caller reads their own back — never another key's —
# in a canonical form signed with the system's Ed25519 key, whose
# public half is at /peah/ledger_key. See VavPipeline.Ledger.
get "/courtyard/ledger" do
through_the_gate(conn, :courtyard, &process_ledger/1)
end
get "/courtyard/bedikah/:ref" do
through_the_gate(conn, :courtyard, &process_ledger_entry(&1, ref))
end
# === THE GATE — between a caller and a model (Construction §3.10) ===
#
# The caller's own request, in the model's own API shape, under the
@@ -243,6 +258,18 @@ defmodule VavPipeline.BigdeiKodesh.Router do
through_the_gate(conn, :chatzer, &process_hakhel/1)
end
# The ledger's public key: anyone verifies a signed record with it.
get "/peah/ledger_key" do
case VavPipeline.Ledger.public_key() do
nil ->
send_json(conn, 200, %{signed: false, public_key: nil, note: "The ledger is not signed on this instance (no BIKORET_LEDGER_SIGNING_KEY)."})
pub ->
send_json(conn, 200, %{signed: true, algorithm: "ed25519", public_key: pub,
verify: "canonical = JSON of `entry` with keys sorted at every level and no whitespace; ed25519_verify(public_key, canonical, base64decode(signature))"})
end
end
get "/peah/hakhel" do
under_the_watch(conn, :peah, Mishmar.peer(conn), fn conn ->
records =
@@ -538,7 +565,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
{:ok, side} ->
when_available(conn, fn ->
respond_bedikah(conn, VavPipeline.Bedikah.examine(side.output, side.model, side.query, sources: side.sources))
respond_bedikah(conn, VavPipeline.Bedikah.examine(side.output, side.model, side.query, sources: side.sources, key_id: caller_key_id(conn)))
end)
end
end
@@ -551,7 +578,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
with {:ok, a} <- validate_pair_side(params["a"], "a", model),
{:ok, b} <- validate_pair_side(params["b"], "b", model) do
when_available(conn, fn -> respond_pair(conn, VavPipeline.Bedikah.Pair.weigh(a, b)) end)
when_available(conn, fn -> respond_pair(conn, VavPipeline.Bedikah.Pair.weigh(a, b, key_id: caller_key_id(conn))) end)
else
{:error, issue, correction} ->
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{issue: issue, correction: correction, resubmit: true})
@@ -736,7 +763,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
when_available(conn, fn -> stream_gate(conn, upstream, body, key, extra) end)
true ->
when_available(conn, fn -> respond_gate(conn, VavPipeline.Gate.pass(upstream, body, key, rebuke: rebuke, extra_headers: extra)) end)
when_available(conn, fn -> respond_gate(conn, VavPipeline.Gate.pass(upstream, body, key, rebuke: rebuke, extra_headers: extra, key_id: caller_key_id(conn))) end)
end
else
{:error, issue, correction} ->
@@ -823,7 +850,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
end
}
case VavPipeline.Gate.Stream.pass(upstream, body, key, callbacks, extra_headers: extra) do
case VavPipeline.Gate.Stream.pass(upstream, body, key, callbacks, extra_headers: extra, key_id: caller_key_id(conn)) do
{:ok, %{status: 200, emitter: conn}} -> conn
{:ok, %{status: status, reply: reply}} -> conn |> put_resp_header("x-bikoret-verdict", "none") |> send_json(status, reply)
other -> respond_gate(conn, other)
@@ -1076,6 +1103,58 @@ defmodule VavPipeline.BigdeiKodesh.Router do
# Internal — Sanhedrin processing
# ============================================================
# A digest of the caller's courtyard key — the ledger's owner column. Never the key.
defp caller_key_id(conn) do
case Gate.bearer(conn) do
{:ok, key} -> VavPipeline.Ledger.key_id(key)
_ -> nil
end
end
defp process_ledger(conn) do
if Process.whereis(VavPipeline.Repo) do
key_id = caller_key_id(conn)
limit = conn.params |> Map.get("limit", "50") |> Integer.parse() |> then(fn {n, _} -> n; :error -> 50 end)
before =
case Map.get(conn.params, "before") do
s when is_binary(s) -> (case DateTime.from_iso8601(s) do {:ok, t, _} -> t; _ -> nil end)
_ -> nil
end
records = VavPipeline.L3.Store.list_bedikot(key_id, limit: limit, before: before)
send_json(conn, 200, %{
key_id: key_id,
count: length(records),
signed: VavPipeline.Ledger.public_key() != nil,
public_key: "GET /peah/ledger_key",
entries: Enum.map(records, &VavPipeline.Ledger.render/1),
next: (case List.last(records) do nil -> nil; r -> "GET /courtyard/ledger?before=#{DateTime.to_iso8601(r.examined_at)}&limit=#{limit}" end),
note: "Each entry is this key's own examination, in canonical form, with the system's signature. The text examined is not kept; its SHA-256 and length are."
})
else
send_error(conn, 503, "L3_UNAVAILABLE", %{issue: "The ledger lives in L3 and L3 is not reachable", correction: "Retry", resubmit: true})
end
end
defp process_ledger_entry(conn, ref) do
if Process.whereis(VavPipeline.Repo) do
key_id = caller_key_id(conn)
case VavPipeline.L3.Store.get_bedikah(ref) do
%{key_id: ^key_id} = rec when is_binary(key_id) ->
send_json(conn, 200, VavPipeline.Ledger.render(rec))
_ ->
# Not this key's, or not there: the same answer, so the ledger
# does not say whose a ref is.
send_error(conn, 404, "NOT_FOUND", %{issue: "No bedikah #{ref} in this key's ledger", correction: "Only examinations brought under this key are listed", resubmit: false})
end
else
send_error(conn, 503, "L3_UNAVAILABLE", %{issue: "The ledger lives in L3 and L3 is not reachable", correction: "Retry", resubmit: true})
end
end
defp process_sanhedrin(conn) do
params = conn.body_params
+1 -1
View File
@@ -180,7 +180,7 @@ defmodule VavPipeline.Doorpost do
# changes; the update is a deliberate operator commit. A bare
# hash-update commit (without a corresponding spec change in the
# same commit) is itself a drift signal.
@expected_hash "66aa7874fae4875793791ce6ca7bbe55302109d5e4444bcb36d578dfe6e98a0d"
@expected_hash "02ddf313a41a885a0f4e91f07d586bf2bb6da7ab90ad64ef9b7743ba872aa333"
@doc "Return the list of files that constitute the category-grade specification."
@spec files() :: [String.t()]
@@ -37,6 +37,8 @@ defmodule VavPipeline.L3.BedikahRecord do
field :violations, {:array, :string}, default: []
field :verdict, :string
field :examined_at, :utc_datetime
# The ledger: a digest of the courtyard key that brought the offering (VavPipeline.Ledger.key_id/1). Never the key.
field :key_id, :string
timestamps(type: :utc_datetime, updated_at: false)
end
@@ -46,7 +48,7 @@ defmodule VavPipeline.L3.BedikahRecord do
# offering protocol extracted nothing, and that record must be kept.
# Ecto's cast turns "" into nil and validate_required calls "" blank;
# both are told otherwise here.
@optional [:query, :vital_content, :propositions, :references, :authority_claims, :actions, :sources, :claims, :structure, :frames, :rebukes, :mesorah, :coupling_trace, :violations]
@optional [:query, :vital_content, :propositions, :references, :authority_claims, :actions, :sources, :claims, :structure, :frames, :rebukes, :mesorah, :coupling_trace, :violations, :key_id]
def changeset(record, attrs) do
record
+21
View File
@@ -75,6 +75,27 @@ defmodule VavPipeline.L3.Store do
|> Repo.insert()
end
@doc "One bedikah by its ref, or nil."
def get_bedikah(ref) when is_binary(ref) do
Repo.get_by(VavPipeline.L3.BedikahRecord, bedikah_ref: ref)
end
@doc """
The ledger of one courtyard key: its bedikot newest first, at most
`limit`, examined at or before `before` when given. A record without a
key_id (from before the ledger) belongs to no one and is not listed.
"""
def list_bedikot(key_id, opts \\ []) when is_binary(key_id) do
import Ecto.Query, only: [from: 2]
limit = opts |> Keyword.get(:limit, 50) |> min(200) |> max(1)
q = from b in VavPipeline.L3.BedikahRecord, where: b.key_id == ^key_id, order_by: [desc: b.examined_at, desc: b.id], limit: ^limit
q = case Keyword.get(opts, :before) do
%DateTime{} = t -> from b in q, where: b.examined_at <= ^t
_ -> q
end
Repo.all(q)
end
@doc """
Record an examination of an external model's output (Construction §3.9).
"""
+140
View File
@@ -0,0 +1,140 @@
defmodule VavPipeline.Ledger do
@moduledoc """
The provenance ledger — a bedikah record as something its caller can
carry away and have checked by someone else.
Every bedikah is engraved in L3 with the output's hash and length, the
source digests, the coupling trace, the claims, the violations and the
verdict — never the text (Tamid §1.1). Until 2026-10-21 the caller had
no way back to it. Now a record is read back under the courtyard key
that brought the offering (`key_id`: a digest of the key, never the
key), rendered in one canonical form, and signed with the system's
Ed25519 key, whose public half anyone may fetch at `/peah/ledger_key`.
A regulator, a client or an audit can verify a record without asking
Bikoret: the canonical bytes, the signature, the public key.
Devarim 19:15 — a matter is established by witnesses. The ledger is
the witness that does not depend on the one who offered, and the
signature is what lets a third party trust the witness.
"""
@doc "A digest of a courtyard key, for the record. The key itself is never written."
@spec key_id(String.t() | nil) :: String.t() | nil
def key_id(nil), do: nil
def key_id(key) when is_binary(key),
do: :crypto.hash(:sha256, key) |> Base.encode16(case: :lower) |> binary_part(0, 32)
@doc """
The record's fields that a verifier signs and checks — every field of
the examination, none of the database's own (id, inserted_at), and not
the key_id (whose key it was is the caller's business, not the record's).
"""
@ledger_fields ~w(bedikah_ref provenance query output_sha256 output_length vital_content propositions references authority_claims actions sources claims structure frames rebukes mesorah coupling_trace violations verdict examined_at)a
@spec ledger_fields() :: [atom()]
def ledger_fields, do: @ledger_fields
@doc "The record as a plain map of its ledger fields, dates in ISO 8601."
@spec entry(struct() | map()) :: map()
def entry(record) do
record
|> unstruct()
|> Map.take(@ledger_fields)
|> Map.new(fn
{k, %DateTime{} = dt} -> {k, DateTime.to_iso8601(dt)}
{k, v} -> {k, v}
end)
end
defp unstruct(%_{} = s), do: Map.from_struct(s)
defp unstruct(m), do: m
@doc """
The canonical bytes of an entry: JSON with every object's keys sorted,
no insignificant whitespace, UTF-8. Two implementations that agree on
this agree on the signature.
"""
@spec canonical(map()) :: binary()
def canonical(entry) when is_map(entry), do: entry |> sort_keys() |> Jason.encode!()
defp sort_keys(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp sort_keys(map) when is_map(map) and not is_struct(map) do
map
|> Enum.map(fn {k, v} -> {to_string(k), sort_keys(v)} end)
|> Enum.sort_by(fn {k, _} -> k end)
|> Jason.OrderedObject.new()
end
defp sort_keys(list) when is_list(list), do: Enum.map(list, &sort_keys/1)
defp sort_keys(other), do: other
@doc """
Sign canonical bytes with the system's Ed25519 key. `nil` when no key
is configured — the record is still given, marked unsigned; nothing is
signed with a key made up on the spot.
"""
@spec sign(binary(), binary() | nil) :: binary() | nil
def sign(bytes, seed \\ seed())
def sign(_bytes, nil), do: nil
def sign(bytes, seed) when is_binary(seed) and byte_size(seed) == 32 do
:crypto.sign(:eddsa, :none, bytes, [seed, :ed25519]) |> Base.encode64()
end
@doc "Verify a base64 signature over canonical bytes with a base64 public key."
@spec verify(binary(), String.t(), String.t()) :: boolean()
def verify(bytes, signature_b64, public_key_b64) do
with {:ok, sig} <- Base.decode64(signature_b64),
{:ok, pub} <- Base.decode64(public_key_b64) do
:crypto.verify(:eddsa, :none, bytes, sig, [pub, :ed25519])
else
_ -> false
end
end
@doc "The public key (base64) a verifier uses, or nil when the ledger is unsigned."
@spec public_key(binary() | nil) :: String.t() | nil
def public_key(seed \\ seed())
def public_key(nil), do: nil
def public_key(seed) when is_binary(seed) and byte_size(seed) == 32 do
{pub, _priv} = :crypto.generate_key(:eddsa, :ed25519, seed)
Base.encode64(pub)
end
@doc """
A record rendered for the caller: the entry, its canonical form's
SHA-256 (so the caller can confirm what was signed without re-encoding),
the signature, and how to verify.
"""
@spec render(struct() | map()) :: map()
def render(record) do
e = entry(record)
bytes = canonical(e)
sig = sign(bytes)
%{
entry: e,
canonical_sha256: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower),
signature: sig,
signed: sig != nil,
algorithm: "ed25519 over the canonical JSON of `entry` (keys sorted at every level, no whitespace, UTF-8)",
public_key: "GET /peah/ledger_key"
}
end
# The 32-byte seed from the environment (base64), or nil.
defp seed do
case Application.get_env(:vav_pipeline, :ledger_signing_key) do
nil -> nil
"" -> nil
b64 ->
case Base.decode64(String.trim(b64)) do
{:ok, s} when byte_size(s) == 32 -> s
_ -> nil
end
end
end
end