Tablet 46: the watch — rate limits at every gate, per IP at the pe'ah, per key inside

Tamid §1.1 wrote the pe'ah's limit (10/min, 100/h per IP); nothing
enforced it, on any tier, and every admitted request spends manna.

VavPipeline.BigdeiKodesh.Mishmar: fixed minute and hour windows per
subject in ETS, the subject stored only as sha256(boot salt, tier,
subject) — no IP and no key is ever in memory as itself, the sweep
drops closed windows, nothing survives a restart (the Tamid's "not
logged beyond the current request", kept in memory too). A refused
knock is not counted. 429 RATE_LIMITED with Retry-After and the manna
explanation (Shemot 16:18). Pe'ah per peer address; courtyard and
chatzer per key, after the gate (a wrong key is 401 and never counted).
Numbers: pe'ah 10/100 (Tamid), courtyard 20/200 and chatzer 5/60
(from Construction §2.3; config :rate_limits).

Service shorashim-external: externalTrafficPolicy Local. With Cluster
the pod saw every caller as the node, 91.92.153.113 (observed via
/proc/net/tcp) — a per-IP limit would have been one bucket for the
world.

Spec: tamid §1.1 (watch, 429 rows, the Service note), KOHEN.md.
Doorpost 42b38626… → e4e3d56d…. tablet46_mishmar_test; Elixir 533/0
(L3 on and off).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Scott KarpandClaude Fable 5.1 committed 2026-10-06 16:08:57 +00:00
1 parent 79eb34840f
commit 54ca651f37
12 files changed
+406 -17

No files matched your search

+8 -1
View File
@@ -20,7 +20,14 @@ config :vav_pipeline,
korban_socket_path: "/tmp/shorashim/korban.sock",
# Courtyard / chatzer keys (BigdeiKodesh.Gate). nil → the gate is shut.
# Construction §4.6: a file, never an environment variable.
api_keys_file: nil
api_keys_file: nil,
# The watch (BigdeiKodesh.Mishmar). pe'ah is Tamid §1.1 verbatim; the
# other two derive from Construction §2.3 and may be tuned here.
rate_limits: [
peah: [minute: 10, hour: 100],
courtyard: [minute: 20, hour: 200],
chatzer: [minute: 5, hour: 60]
]
# L3 — Oral Tradition (PostgreSQL)
# Architecture §3.7: Append-only. INSERT allowed. Never UPDATE. Never DELETE.
+7
View File
@@ -7,6 +7,13 @@ config :vav_pipeline,
l1_mount_point: "../l1",
manna_provision_file: "test/fixtures/manna/today.json",
api_keys_file: "test/fixtures/api_keys",
# Wide open for the suite (Day 6/7 tests knock many times a second);
# tablet46_mishmar_test narrows them and resets.
rate_limits: [
peah: [minute: 10_000, hour: 100_000],
courtyard: [minute: 10_000, hour: 100_000],
chatzer: [minute: 10_000, hour: 100_000]
],
cycle_boundary_check_interval_ms: :timer.hours(24),
http_port: 0
+2 -1
View File
@@ -47,7 +47,8 @@ defmodule VavPipeline.Application do
{VavPipeline.VoiceMonitor, []},
{VavPipeline.MachtzitHaShekel, []},
# Tablet 30 — the index that lets a question name its own verse
{VavPipeline.L1Index, []}
{VavPipeline.L1Index, []},
{VavPipeline.BigdeiKodesh.Mishmar, []}
]
|> maybe_add_http_server()
|> maybe_add_repo()
@@ -85,7 +85,9 @@ defmodule VavPipeline.BigdeiKodesh.Gate do
# ------------------------------------------------------------
defp bearer(conn) do
@doc "The bearer key on the request, if the header has one."
@spec bearer(Plug.Conn.t()) :: {:ok, String.t()} | {:error, :no_header}
def bearer(conn) do
case Plug.Conn.get_req_header(conn, "authorization") do
["Bearer " <> key] when key != "" -> {:ok, String.trim(key)}
_ -> {:error, :no_header}
@@ -0,0 +1,180 @@
defmodule VavPipeline.BigdeiKodesh.Mishmar do
@moduledoc """
The watch at each gate — how often one hand may knock.
Bamidbar 18:4 — *v'shamru et mishmeret ohel mo'ed*: the Levites keep
the watch of the tent. The watch does not decide who may enter (that
is the Gate); it decides how often. The day's manna is one portion for
everyone who comes; without a watch one caller eats it all before noon.
Tamid §1.1 fixes the pe'ah: 10 requests a minute and 100 an hour per
IP, and "no identification stored — IP is used for rate-limiting only,
not logged beyond the current request." For the courtyard and the
chatzer the Tamid says only that 429 exists and carries the manna
explanation; the numbers here come from Construction §2.3 (peak
korbanot per hour 200; five Sanhedrin panels at once) and are
configuration, not law — `config :vav_pipeline, :rate_limits`.
**What is kept.** Two fixed windows per subject — the current minute
and the current hour — as counters in an ETS table. The subject is
not the IP and not the key: it is `sha256(salt <> subject)` with a
salt drawn at boot and never written anywhere. The table cannot be
read back into a list of who called; it cannot survive a restart;
the sweep drops a window a few minutes after it closes. This is the
Tamid's "not logged beyond the current request", kept in memory too.
**What is answered.** `{:ok, remaining_this_minute}` or
`{:error, window, retry_after_seconds}`. The router turns the latter
into 429 with `Retry-After`.
"""
use GenServer
require Logger
@table __MODULE__
@sweep_ms :timer.minutes(1)
@type tier :: :peah | :courtyard | :chatzer
@type subject :: binary()
@default_limits [
# Tamid §1.1, verbatim.
peah: [minute: 10, hour: 100],
# Construction §2.3: 200 korbanot an hour at peak — one key may not
# be the whole peak. 20 a minute lets a client work; 200 an hour is
# the system's own ceiling.
courtyard: [minute: 20, hour: 200],
# Construction §2.3: five panels at once, deliberation measured in
# minutes. A key convening more than five cases a minute is not a
# court, it is a loop.
chatzer: [minute: 5, hour: 60]
]
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@doc "The configured limits for a tier, `[minute: n, hour: n]`."
@spec limits(tier()) :: keyword()
def limits(tier) do
configured = Application.get_env(:vav_pipeline, :rate_limits, [])
Keyword.get(configured, tier) || Keyword.fetch!(@default_limits, tier)
end
@doc """
Count one knock by `subject` at `tier`.
Returns `{:ok, remaining}` (knocks left this minute) or
`{:error, :minute | :hour, retry_after_seconds}`. A refused knock is
not counted: a caller who keeps knocking is not punished further, but
is not let in either.
When the watch is not running (tests without the supervisor) every
knock is allowed — and logged once as MISHMAR_ABSENT, because that is
not a state production may be in.
"""
@spec knock(tier(), subject(), integer()) ::
{:ok, non_neg_integer()} | {:error, :minute | :hour, pos_integer()}
def knock(tier, subject, now \\ System.os_time(:second)) when is_binary(subject) do
case :ets.whereis(@table) do
:undefined ->
absent()
{:ok, 0}
_ ->
tier_limits = limits(tier)
per_minute = Keyword.fetch!(tier_limits, :minute)
per_hour = Keyword.fetch!(tier_limits, :hour)
id = hashed(tier, subject)
minute = div(now, 60)
hour = div(now, 3600)
minute_count = :ets.lookup_element(@table, {id, :minute, minute}, 2, 0)
hour_count = :ets.lookup_element(@table, {id, :hour, hour}, 2, 0)
cond do
hour_count >= per_hour ->
{:error, :hour, (hour + 1) * 3600 - now}
minute_count >= per_minute ->
{:error, :minute, (minute + 1) * 60 - now}
true ->
:ets.update_counter(@table, {id, :minute, minute}, {2, 1}, {{id, :minute, minute}, 0})
:ets.update_counter(@table, {id, :hour, hour}, {2, 1}, {{id, :hour, hour}, 0})
{:ok, per_minute - minute_count - 1}
end
end
end
@doc "The subject for a request at the pe'ah: its peer address, as text."
@spec peer(Plug.Conn.t()) :: subject()
def peer(%Plug.Conn{remote_ip: ip}) do
case :inet.ntoa(ip) do
{:error, _} -> inspect(ip)
chars -> List.to_string(chars)
end
end
@doc "Forget every window. For tests."
def reset(server \\ __MODULE__), do: GenServer.call(server, :reset)
@doc "Drop the windows that have closed. Runs on its own every minute; callable for tests."
def sweep(server \\ __MODULE__, now \\ System.os_time(:second)), do: GenServer.call(server, {:sweep, now})
# ------------------------------------------------------------
@impl true
def init(_opts) do
:ets.new(@table, [:named_table, :public, :set, write_concurrency: true, read_concurrency: true])
# The salt lives only in this process's state and in the (private)
# persistent key below. It is never logged, never written, and is
# different after every restart.
salt = :crypto.strong_rand_bytes(32)
:persistent_term.put({__MODULE__, :salt}, salt)
Process.send_after(self(), :sweep, @sweep_ms)
{:ok, %{}}
end
@impl true
def handle_call(:reset, _from, state) do
:ets.delete_all_objects(@table)
{:reply, :ok, state}
end
def handle_call({:sweep, now}, _from, state) do
do_sweep(now)
{:reply, :ok, state}
end
@impl true
def handle_info(:sweep, state) do
do_sweep(System.os_time(:second))
Process.send_after(self(), :sweep, @sweep_ms)
{:noreply, state}
end
# A minute window is dead once the minute after it has passed; an
# hour window once the hour after it has. Nothing older is kept.
defp do_sweep(now) do
minute = div(now, 60)
hour = div(now, 3600)
:ets.select_delete(@table, [
{{{:_, :minute, :"$1"}, :_}, [{:<, :"$1", minute - 1}], [true]},
{{{:_, :hour, :"$1"}, :_}, [{:<, :"$1", hour - 1}], [true]}
])
end
defp hashed(tier, subject) do
salt = :persistent_term.get({__MODULE__, :salt})
:crypto.hash(:sha256, [salt, Atom.to_string(tier), 0, subject])
end
defp absent do
unless :persistent_term.get({__MODULE__, :absent_logged}, false) do
:persistent_term.put({__MODULE__, :absent_logged}, true)
Logger.warning("MISHMAR_ABSENT: the watch is not running — no rate limit is applied")
end
end
end
@@ -28,7 +28,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
require Logger
alias VavPipeline.BigdeiKodesh.Gate
alias VavPipeline.BigdeiKodesh.{Gate, Mishmar}
# Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger
# replaces Plug.Logger; sanitized paths on /peah/* honor the
@@ -63,17 +63,21 @@ defmodule VavPipeline.BigdeiKodesh.Router do
end
post "/peah/ask" do
case conn.body_params do
%{"question" => question} when is_binary(question) and question != "" ->
process_peah(conn, question)
# Tamid §1.1: 10 a minute, 100 an hour, per IP; the IP is used for
# this and nothing else (Mishmar keeps a salted hash, not the IP).
under_the_watch(conn, :peah, Mishmar.peer(conn), fn conn ->
case conn.body_params do
%{"question" => question} when is_binary(question) and question != "" ->
process_peah(conn, question)
_ ->
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
issue: "Missing or empty 'question' field",
correction: "Submit JSON with a 'question' string",
resubmit: true
})
end
_ ->
send_error(conn, 422, "KIYOR_VALIDATION_FAILED", %{
issue: "Missing or empty 'question' field",
correction: "Submit JSON with a 'question' string",
resubmit: true
})
end
end)
end
# ============================================================
@@ -129,6 +133,32 @@ defmodule VavPipeline.BigdeiKodesh.Router do
})
end
# The watch (Mishmar): how often one subject may knock at a tier.
# 429 carries the manna explanation Tamid §1.1 asks for, and Retry-After.
defp under_the_watch(conn, tier, subject, serve) do
case Mishmar.knock(tier, subject) do
{:ok, _remaining} ->
serve.(conn)
{:error, window, retry_after} ->
limits = Mishmar.limits(tier)
conn
|> put_resp_header("retry-after", Integer.to_string(retry_after))
|> send_error(429, "RATE_LIMITED", %{
issue:
"Too many requests this #{window}: the #{tier} allows " <>
"#{limits[:minute]} a minute and #{limits[:hour]} an hour per caller",
correction: "Wait #{retry_after}s and resubmit",
manna:
"The day's provision is one portion for everyone who comes (Shemot 16:18 — " <>
"the one who gathered much had nothing over). The watch keeps one caller " <>
"from gathering the whole omer.",
resubmit: true
})
end
end
# === COURTYARD — Authenticated ===
#
# The key is checked against the keys file (VavPipeline.BigdeiKodesh.Gate),
@@ -136,7 +166,10 @@ defmodule VavPipeline.BigdeiKodesh.Router do
defp through_the_gate(conn, tier, serve) do
case Gate.admit(conn, tier) do
{:ok, _granted} ->
serve.(conn)
# Past the gate, the key is the hand that knocks: the watch
# counts per key, not per IP. A refused key never reaches it.
{:ok, key} = Gate.bearer(conn)
under_the_watch(conn, tier, key, serve)
{:error, :tier_too_low} ->
send_error(conn, 403, "FORBIDDEN", %{
@@ -951,6 +984,7 @@ defmodule VavPipeline.BigdeiKodesh.Router do
defp error_type("HALT" <> _), do: "cardinal_sin_detected"
defp error_type("UNAUTHORIZED"), do: "authentication_required"
defp error_type("FORBIDDEN"), do: "insufficient_tier"
defp error_type("RATE_LIMITED"), do: "too_many_requests"
defp error_type("TEIKU" <> _), do: "unresolvable"
defp error_type("LO_TOV"), do: "specification_mismatch"
defp error_type("CHATAT" <> _), do: "correction_needed"
+1 -1
View File
@@ -175,7 +175,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 "42b38626c6e2bd3e6fe10ed7f4cb34ee8dae118205379b7d822f278fe7eaa110"
@expected_hash "e4e3d56de7422280b5562c4a7ebce02fe05225a938bbce26d6e52f26b6b240eb"
@doc "Return the list of files that constitute the category-grade specification."
@spec files() :: [String.t()]
-1
View File
@@ -1 +0,0 @@
{"consumed":{"api_calls_external":0,"compute_units":3,"memory_mb":0,"sanhedrin_panels_max":0,"storage_delta_mb":0},"cycle_date":"2026-10-06","written_at":"2026-10-06T15:15:24.042079Z"}
+135
View File
@@ -0,0 +1,135 @@
defmodule VavPipeline.Tablet46MishmarTest do
@moduledoc """
Tablet 46 (2026-10-06) — no gate had a watch.
Tamid §1.1 writes the pe'ah's limit in the spec; nothing enforced it,
on any tier. Every admitted request spends manna; one caller could
gather the whole omer.
"""
use ExUnit.Case, async: false
import Plug.Test
import Plug.Conn
alias VavPipeline.BigdeiKodesh.{Mishmar, Router}
@opts Router.init([])
setup do
original = Application.get_env(:vav_pipeline, :rate_limits)
Application.put_env(:vav_pipeline, :rate_limits,
peah: [minute: 3, hour: 5],
courtyard: [minute: 2, hour: 100],
chatzer: [minute: 10_000, hour: 100_000]
)
Mishmar.reset()
on_exit(fn ->
Application.put_env(:vav_pipeline, :rate_limits, original)
Mishmar.reset()
end)
:ok
end
describe "Mishmar.knock/3" do
test "counts the minute, then the hour, per subject; a refusal is not counted" do
t = 1_000_000 * 60
assert {:ok, 2} = Mishmar.knock(:peah, "a", t)
assert {:ok, 1} = Mishmar.knock(:peah, "a", t + 1)
assert {:ok, 0} = Mishmar.knock(:peah, "a", t + 2)
assert {:error, :minute, 57} = Mishmar.knock(:peah, "a", t + 3)
assert {:error, :minute, 57} = Mishmar.knock(:peah, "a", t + 3)
# Another subject is another count.
assert {:ok, 2} = Mishmar.knock(:peah, "b", t + 3)
# The next minute opens — but the hour (5) is nearly spent: 3 so far.
assert {:ok, 2} = Mishmar.knock(:peah, "a", t + 60)
assert {:ok, 1} = Mishmar.knock(:peah, "a", t + 61)
assert {:error, :hour, retry} = Mishmar.knock(:peah, "a", t + 62)
assert retry == 3600 - rem(t + 62, 3600)
# The next hour opens.
assert {:ok, 2} = Mishmar.knock(:peah, "a", t + 3600)
end
test "tiers are counted apart" do
t = 2_000_000 * 60
assert {:ok, 1} = Mishmar.knock(:courtyard, "k", t)
assert {:ok, 0} = Mishmar.knock(:courtyard, "k", t)
assert {:error, :minute, _} = Mishmar.knock(:courtyard, "k", t)
assert {:ok, 2} = Mishmar.knock(:peah, "k", t)
end
test "the sweep drops closed windows and nothing else" do
t = 3_000_000 * 60
assert {:ok, _} = Mishmar.knock(:peah, "old", t)
assert {:ok, _} = Mishmar.knock(:peah, "new", t + 7200)
:ok = Mishmar.sweep(Mishmar, t + 7200)
# "old" has been forgotten entirely; "new" is still counted.
assert :ets.info(Mishmar, :size) == 2
assert {:ok, 1} = Mishmar.knock(:peah, "new", t + 7200)
end
test "the table holds hashes, never the subject" do
:ok = Mishmar.reset()
{:ok, _} = Mishmar.knock(:peah, "203.0.113.7")
for {{id, _, _}, _} <- :ets.tab2list(Mishmar) do
assert is_binary(id) and byte_size(id) == 32
refute id =~ "203.0.113"
end
end
end
describe "at the router" do
test "the pe'ah: the fourth knock from one address in a minute is 429 with Retry-After and the manna explanation" do
statuses = for _ <- 1..4, do: ask({203, 0, 113, 9}).status
assert Enum.take(statuses, 3) |> Enum.all?(&(&1 != 429))
conn = ask({203, 0, 113, 9})
assert conn.status == 429
[retry] = get_resp_header(conn, "retry-after")
assert String.to_integer(retry) in 1..60
body = Jason.decode!(conn.resp_body)
assert body["error"]["code"] == "RATE_LIMITED"
assert body["error"]["guidance"]["manna"] =~ "Shemot 16:18"
# Another address is not held back by this one.
refute ask({203, 0, 113, 10}).status == 429
end
test "the courtyard: per key, and only past the gate" do
# A wrong key knocks all it likes and is never counted — 401, not 429.
for _ <- 1..5, do: assert(bedikah("Bearer wrong").status == 401)
assert bedikah("Bearer test-token").status != 429
assert bedikah("Bearer test-token").status != 429
conn = bedikah("Bearer test-token")
assert conn.status == 429
assert Jason.decode!(conn.resp_body)["error"]["guidance"]["issue"] =~ "courtyard allows 2 a minute"
# The chatzer key is its own hand, even at the same door.
refute bedikah("Bearer chatzer-test-token").status == 429
end
end
defp ask(ip) do
%{conn(:post, "/peah/ask", Jason.encode!(%{question: "x"})) | remote_ip: ip}
|> put_req_header("content-type", "application/json")
|> Router.call(@opts)
end
defp bedikah(auth) do
conn(:post, "/courtyard/bedikah", Jason.encode!(%{output: "", model: "gpt"}))
|> put_req_header("content-type", "application/json")
|> put_req_header("authorization", auth)
|> Router.call(@opts)
end
end