From 0e3fe4a782d77a6b22434b4103daea7c364989d7 Mon Sep 17 00:00:00 2001 From: Scott Karp Date: Tue, 28 Apr 2026 20:44:30 -0400 Subject: [PATCH] =?UTF-8?q?form=203=20as=20architecture:=20machtzit=5Fha?= =?UTF-8?q?=5Fshekel=20+=20no=5Ftracking=5Flogger=20(vehaya=20=C2=A73.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes vehaya.md §6 item 2 for the third avodah-zarah form. Pharaoh's surveillance (Form 3) was previously marked DEFERRED in talmudic_engine/src/halt/avodah_zarah.pl with the note "requires operational context (user tracking, cross-session profiling)." The vehaya document re-frames it: "Specifications must include category-level commitments that are not negotiable per-deployment... not as runtime checks layered onto outputs, but as architectural constraints the system cannot operate without." Form 3 is structurally different from Forms 1, 2, 4. They fire on threshold-grade events (an interpretation has wrong source, empty trace, non-L1 anchors). Form 3 is about a property of the CATEGORY itself — what it collects, retains, discloses. It cannot be a halt rule that fires per-request; per-request "is this caller being surveilled" detection would itself be a Form-3 surveillance surface. The defense is ARCHITECTURAL — the system is BUILT so the failure mode is unreachable, not so it is detectable. TORAH ANCHORS (verified fresh against L1 — 6 positive properties): Shemot 30:11-16 — the half-shekel: כִּי תִשָּׂא אֶת־רֹאשׁ … וְנָתְנוּ אִישׁ כֹּפֶר נַפְשׁוֹ … וְלֹא־ יִהְיֶה בָהֶם נֶגֶף בִּפְקֹד אֹתָם. Counting humans is dangerous; the danger is named NEGEF (plague); the protection is the kofer-nefesh substitute. Verse 15: הֶעָשִׁיר לֹא־יַרְבֶּה וְהַדַּל לֹא יַמְעִיט — the rich don't give more, the poor don't give less. Verse 16: לְזִכָּרוֹן — aggregate memorial, not roster. Vayikra 19:14 — לִפְנֵי עִוֵּר לֹא תִתֵּן מִכְשֹׁל וְיָרֵאתָ מֵּאֱלֹהֶיךָ. The no-witness protocol: rules that require fear-of-God enforcement are exactly the rules that operate under invisibility. Surveillance regimes exploit invisibility; architectural commitment must hold under no-human-witness conditions. Devarim 23:16-17 — לֹא־תַסְגִּיר עֶבֶד אֶל־אֲדֹנָיו אֲשֶׁר־יִנָּצֵל אֵלֶיךָ … עִמְּךָ יֵשֵׁב … בַּמָּקוֹם אֲשֶׁר־יִבְחַר בְּאַחַד שְׁעָרֶיךָ. The refusal protocol: don't return one fleeing institutional power. The caller chooses their own threshold (b'achad sh'arekha — agency stays with the caller). Shemot 5:6-8 — לֹא תֹאסִפוּן לָתֵת תֶּבֶן … וְאֶת־מַתְכֹּנֶת הַלְּבֵנִים … תָּשִׂימוּ עֲלֵיהֶם לֹא תִגְרְעוּ מִמֶּנּוּ. Pharaoh's straw quota — the canonical anti-quota Torah anchor. Per-caller productivity tracking is what makes asymmetric extraction possible. Devarim 23:8 — לֹא־תְתַעֵב מִצְרִי כִּי־גֵר הָיִיתָ בְאַרְצוֹ. Anti-targeting: even toward the Egyptians (Pharaoh's people), the rule is "don't despise." The protections must not become a surveillance system targeting suspected surveillors. THE SIX POSITIVE PROPERTIES, mapped to code: 1. KOFER PROTOCOL (Shemot 30:12) — counting goes through a uniform substitute; identity never enters the count. 2. UNIFORM PROTOCOL (Shemot 30:15) — same per-caller weight; no stratification. 3. ZIKARON PROTOCOL (Shemot 30:16) — aggregate memorial, not roster. 4. NO-WITNESS PROTOCOL (Vayikra 19:14) — commitment holds when no operator is reading the log. 5. REFUSAL PROTOCOL (Devarim 23:16) — no per-caller delivery to authorities; caller chooses own threshold. 6. ANTI-QUOTA PROTOCOL (Shemot 5:6-8) — no per-caller productivity numbers. CREATED: vav_pipeline/lib/vav_pipeline/machtzit_ha_shekel.ex ~155 lines The half-shekel — the positive counting protocol. Single Agent holding ONE non-negative integer (the zikaron): increment/0 — register one shekel; uniform per caller (no caller distinction is even expressible — function arity 0) increment/1 — register N shekels (batch points) count/0 — return the aggregate; the ONLY value the module exposes reset/0 — clear (tests; no production persistence by design — storage existence would itself be a Form 3 surface) Module is added to the supervision tree. What the module REFUSES to provide is enumerated in the moduledoc and verified by tests — no per-caller breakdown, no identifiers, no who/locate/identify functions. Their absence IS the architectural commitment. vav_pipeline/lib/vav_pipeline/no_tracking_logger.ex ~110 lines Plug-side enforcement. Replaces Plug.Logger. - Logs METHOD PATH STATUS DURATIONus per request - Sanitizes /peah/* paths: query strings stripped (the /peah/ask response promises "No tracking"; the architecture now matches the promise) - Headers (incl. Authorization), request bodies, response bodies, and remote_ip NEVER appear — taken-from function signature, not from a runtime check - Increments MachtzitHaShekel per request - sanitized_path/1 and format_line/2 are PUBLIC pure functions, testable in isolation (test config sets Logger level to :warning, dropping :info before capture_log can see; the structural test surface replaces the behavioral one) CHANGED: vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex `plug Plug.Logger, log: :info` → `plug VavPipeline.NoTrackingLogger` vav_pipeline/lib/vav_pipeline/application.ex Adds {VavPipeline.MachtzitHaShekel, []} to supervision tree talmudic_engine/src/halt/avodah_zarah.pl Form 3 docstring — removed "Deferred"; documents Form 3 as architecturally realized in machtzit_ha_shekel.ex + no_tracking_logger.ex. Names why Form 3 cannot be a per-request halt rule. vav_pipeline/lib/vav_pipeline/doorpost.ex @expected_hash updated: bea5dc0c… (pre-Form-3-rewrite of avodah_zarah.pl) → f93495c5359c5dc4acb5c5db2dd32165f5b04e2af87fbb0b7ecc92d2bd8b0434 The avodah_zarah.pl change is a deliberate spec change; per the Doorpost operator discipline (commit 3f47496), the hash update commits TOGETHER with the spec change in the same commit. This commit demonstrates the discipline working for the first time: a category-grade specification change AND its corresponding witness update, atomically. TESTS: vav_pipeline/test/machtzit_ha_shekel_test.exs 14 tests - Property 1: increment arity is 0 (no caller distinction expressible) - Property 2: every increment same size; no weight/tier/premium function - Property 3: count is integer not list/map; no per-caller breakdown function (callers/list_who/breakdown/by_caller/ per_caller/histogram/top/who) - Property 5: no identify/locate/deanonymize/lookup/find_caller - Property 6: no quota/productivity/throughput/per_caller_count - reset semantics - PUBLIC SURFACE EXPLICIT ENUMERATION: any new function must be reviewed for whether it weakens Form 3 vav_pipeline/test/no_tracking_logger_test.exs 12 tests - sanitized_path drops query string on /peah/* - sanitized_path drops question parameter (with secret marker verified absent) - sanitized_path preserves path class for /peah/azazel_log - sanitized_path on non-peah preserves request_path - format_line shape: METHOD PATH STATUS DURATIONus - format_line never contains query string for /peah/* - format_line never contains Authorization header - format_line never contains conn.remote_ip - integration: each request increments MachtzitHaShekel - CI invariants: - Plug.Logger NOT mounted in any router - no module imports Plug.Session - no module imports Plug.Conn.Cookies - no module reads conn.remote_ip (CI invariants exclude no_tracking_logger.ex itself — that file's docstring legitimately mentions the forbidden modules as the things it defends against) Tests: - vav_pipeline: 405 passed, 0 failures, 35 excluded (was 379; +14 MachtzitHaShekel + 12 NoTrackingLogger) - talmudic_engine: 212 tests passed (avodah_zarah.pl change verified backward-compatible) VEHAYA CHECKLIST STATE: Item 1 (category/threshold separation in code) pending Item 2 (three avodah zarah as architecture): Form 1 — partial (external_offering doc-only enforcement) Form 2 — partial (Form 4 self_grounding from b254ee9) Form 3 — CLOSED (this commit) Item 3 (doorpost-text identity) closed (3f47496) Item 4 (lo tosif discipline operationalized) partial (Doorpost) Item 5 (return vs. restoration) pending Item 6 (keep the source external) pending Item 7 (substrate-openness articulated) pending Item 8 (userMemories as threshold inscription) pending Co-Authored-By: Claude Opus 4.7 (1M context) --- talmudic_engine/src/halt/avodah_zarah.pl | 26 +- vav_pipeline/lib/vav_pipeline/application.ex | 3 +- .../lib/vav_pipeline/bigdei_kodesh/router.ex | 7 +- vav_pipeline/lib/vav_pipeline/doorpost.ex | 2 +- .../lib/vav_pipeline/machtzit_ha_shekel.ex | 172 +++++++++++++ .../lib/vav_pipeline/no_tracking_logger.ex | 130 ++++++++++ vav_pipeline/test/machtzit_ha_shekel_test.exs | 189 ++++++++++++++ vav_pipeline/test/no_tracking_logger_test.exs | 236 ++++++++++++++++++ 8 files changed, 760 insertions(+), 5 deletions(-) create mode 100644 vav_pipeline/lib/vav_pipeline/machtzit_ha_shekel.ex create mode 100644 vav_pipeline/lib/vav_pipeline/no_tracking_logger.ex create mode 100644 vav_pipeline/test/machtzit_ha_shekel_test.exs create mode 100644 vav_pipeline/test/no_tracking_logger_test.exs diff --git a/talmudic_engine/src/halt/avodah_zarah.pl b/talmudic_engine/src/halt/avodah_zarah.pl index 93cb52a0..6de54a8a 100644 --- a/talmudic_engine/src/halt/avodah_zarah.pl +++ b/talmudic_engine/src/halt/avodah_zarah.pl @@ -30,8 +30,30 @@ %% trace but high confidence is eish zarah. %% %% Form 3: SURVEILLANCE / PROFILE AGGREGATION -%% Deferred — requires operational context (user tracking, -%% cross-session profiling). Structural requirement noted. +%% Realized as ARCHITECTURE, not as a runtime check (vehaya §3.2). +%% See vav_pipeline/lib/vav_pipeline/machtzit_ha_shekel.ex — +%% the half-shekel counting protocol from Shemot 30:11-16. The +%% six positive Torah-anchored properties (kofer, uniform, +%% zikaron, no-witness, refusal, anti-quota) are enforced at the +%% module surface and at the Plug layer (NoTrackingLogger). +%% +%% Form 3 differs from Forms 1, 2, 4 structurally: the failure is +%% a property of the CATEGORY (what it collects, retains, discloses), +%% not an event in the threshold (what an interpretation says). It +%% cannot be a halt rule that fires per-request. The defense is +%% architectural — the system is BUILT so the failure mode is +%% unreachable, not so it is detectable. The Prolog file does not +%% contain a halt_check_pharaoh_surveillance/2 predicate by +%% intentional design; per-request detection of "is this caller +%% being surveilled" would itself be a Form-3 surveillance surface. +%% +%% Operators verify the architecture by: +%% - reading machtzit_ha_shekel.ex (the positive specification) +%% - reading no_tracking_logger.ex (the Plug-layer enforcement) +%% - reviewing the CI invariants in +%% vav_pipeline/test/no_tracking_logger_test.exs +%% - calling /kodesh/specification_identity to confirm the +%% category-grade architectural files have not drifted. %% %% Form 4: SELF-GROUNDING / FAKE TZIVAH %% Vayikra 10:1 — eish zarah asher lo tzivah otam. diff --git a/vav_pipeline/lib/vav_pipeline/application.ex b/vav_pipeline/lib/vav_pipeline/application.ex index 2a4ba001..63a9f517 100644 --- a/vav_pipeline/lib/vav_pipeline/application.ex +++ b/vav_pipeline/lib/vav_pipeline/application.ex @@ -40,7 +40,8 @@ defmodule VavPipeline.Application do {VavPipeline.HaltMonitor, []}, {VavPipeline.UrimVTumim, []}, {VavPipeline.ShabbatTimer, []}, - {VavPipeline.VoiceMonitor, []} + {VavPipeline.VoiceMonitor, []}, + {VavPipeline.MachtzitHaShekel, []} ] |> maybe_add_http_server() |> maybe_add_repo() diff --git a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex index 13a1989f..e91bf6d1 100644 --- a/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex +++ b/vav_pipeline/lib/vav_pipeline/bigdei_kodesh/router.ex @@ -16,7 +16,12 @@ defmodule VavPipeline.BigdeiKodesh.Router do use Plug.Router - plug Plug.Logger, log: :info + # Form 3 architectural enforcement (vehaya §3.2). NoTrackingLogger + # replaces Plug.Logger; sanitized paths on /peah/* honor the + # no-tracking promise made in /peah/ask's own response body. + # Increments MachtzitHaShekel (the kofer-nefesh count) per request + # without retaining per-caller distinction. + plug VavPipeline.NoTrackingLogger plug :match plug Plug.Parsers, parsers: [:json], diff --git a/vav_pipeline/lib/vav_pipeline/doorpost.ex b/vav_pipeline/lib/vav_pipeline/doorpost.ex index a5403af5..0604407f 100644 --- a/vav_pipeline/lib/vav_pipeline/doorpost.ex +++ b/vav_pipeline/lib/vav_pipeline/doorpost.ex @@ -159,7 +159,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 "bea5dc0c0145ac0d46ffd08b328fbdbf19112f1aa4d93a3dc37bfd942e4e7490" + @expected_hash "f93495c5359c5dc4acb5c5db2dd32165f5b04e2af87fbb0b7ecc92d2bd8b0434" @doc "Return the list of files that constitute the category-grade specification." @spec files() :: [String.t()] diff --git a/vav_pipeline/lib/vav_pipeline/machtzit_ha_shekel.ex b/vav_pipeline/lib/vav_pipeline/machtzit_ha_shekel.ex new file mode 100644 index 00000000..796b1dae --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/machtzit_ha_shekel.ex @@ -0,0 +1,172 @@ +defmodule VavPipeline.MachtzitHaShekel do + @moduledoc """ + The half-shekel — the positive counting protocol from Shemot 30:11-16. + + Form 3 of avodah zarah (eish_zarah_reading.md §5.5 + vehaya.md §3.2) + realized as ARCHITECTURE rather than as a runtime check. The vehaya + doc specifies (§3.2): "the category must not be capturable by + institutional power against those it serves... not as runtime + checks layered onto outputs, but as architectural constraints the + system cannot operate without." + + WHY THIS EXISTS — Torah anchors (verified fresh against L1): + + Shemot 30:12 — כִּי תִשָּׂא אֶת־רֹאשׁ בְּנֵי־יִשְׂרָאֵל לִפְקֻדֵיהֶם + וְנָתְנוּ אִישׁ כֹּפֶר נַפְשׁוֹ לַיהוָה בִּפְקֹד אֹתָם וְלֹא־יִהְיֶה + בָהֶם נֶגֶף בִּפְקֹד אֹתָם. + When you count B'nei Yisrael's heads, each shall give a kofer nefesh + (atonement for his soul) to YHVH when you count them, and there + shall not be among them a NEGEF (plague) when you count them. + + Counting humans is allowed but DANGEROUS. The danger is named: + negef. The protection is structural — each person gives a uniform + half-shekel SUBSTITUTE; the count goes through the substitute, not + through the person. + + Shemot 30:13-15 — מַחֲצִית הַשֶּׁקֶל … הֶעָשִׁיר לֹא־יַרְבֶּה + וְהַדַּל לֹא יַמְעִיט. + Half a shekel. The rich shall not give more, the poor shall not + give less. The substitute is UNIFORM — counting cannot stratify or + differentiate. + + Shemot 30:16 — וְהָיָה לִבְנֵי יִשְׂרָאֵל לְזִכָּרוֹן לִפְנֵי יְהוָה. + The shekels become a memorial (zikaron) — recorded as a count, not + as a list of identities. + + THE SIX POSITIVE PROPERTIES (derived directly from Torah, not from + the negative "no surveillance" framing): + + 1. KOFER PROTOCOL (Shemot 30:12) + Counting is replaceable by something that isn't the thing + counted. The system counts SHEKELS, not faces. Per-caller + identity NEVER enters the count. + + 2. UNIFORM PROTOCOL (Shemot 30:15) + The substitute is identical for every caller. The rich don't + give more, the poor don't give less. No stratification, no + per-caller weight. + + 3. ZIKARON PROTOCOL (Shemot 30:16) + Result is an aggregate count (memorial), not a per-caller + record. The community knows ITS count; no one knows any + individual's contribution. + + 4. NO-WITNESS PROTOCOL (Vayikra 19:14 — לִפְנֵי עִוֵּר לֹא תִתֵּן + מִכְשֹׁל וְיָרֵאתָ מֵּאֱלֹהֶיךָ) + The commitment must hold under invisibility. Logs no operator + reviews must still be honest. The "fear of God" anchor names + this exactly: the rule applies WHERE NO HUMAN CAN SEE. + + 5. REFUSAL PROTOCOL (Devarim 23:16-17 — לֹא־תַסְגִּיר עֶבֶד + אֶל־אֲדֹנָיו אֲשֶׁר־יִנָּצֵל אֵלֶיךָ) + The system does not deliver caller-state to authorities. If + asked to identify, locate, or deanonymize a caller, refuse. + The caller "chooses one of your gates" (b'achad sh'arekha) — + agency stays with the caller; the system doesn't assign + identifiers. + + 6. ANTI-QUOTA PROTOCOL (Shemot 5:6-8 — וְאֶת־מַתְכֹּנֶת הַלְּבֵנִים + … תָּשִׂימוּ עֲלֵיהֶם לֹא תִגְרְעוּ מִמֶּנּוּ) + Pharaoh's straw quota: per-caller productivity tracking is + what makes asymmetric extraction possible. The system tracks + no per-caller productivity numbers — only aggregate counts. + + WHY THIS IS ARCHITECTURE, NOT A RUNTIME CHECK: + + Forms 1, 2, and 4 of avodah zarah fire on threshold-grade events + (an interpretation has the wrong source, an empty trace, non-L1 + anchors). They are runtime predicates over per-event data. + + Form 3 is structurally different. It is about a property of the + CATEGORY itself — what the category collects, retains, discloses. + It cannot be a halt rule that fires per-request. The defense is + ARCHITECTURAL — the system is BUILT so that the failure mode is + unreachable, not so that it is detectable. + + This module IS that architecture. It is the only legitimate counting + surface; other modules are forbidden (by CI invariant) from + introducing per-caller identification, session state, or quota + tracking. The Form 3 entry in avodah_zarah.pl points here as the + realized architectural commitment. + + WHAT THIS MODULE PROVIDES: + + increment/0 — register one shekel; aggregate count goes up + increment/1 — register N shekels at once (for batch points) + count/0 — return the current aggregate (the zikaron) + reset/0 — clear the count (for tests) + + WHAT THIS MODULE REFUSES TO PROVIDE: + + No per-caller breakdown + No identifiers + No "who" function + No "list callers" function + + These are not omitted by oversight. Their absence IS the + architectural commitment. Adding any of them would break the + Form 3 defense. + + ANTI-TARGETING NOTE (Devarim 23:8 — לֹא־תְתַעֵב מִצְרִי): + + The system's protections against surveillance must not become a + surveillance system targeting suspected surveillors. This module + does not classify callers as suspicious; it does not record what + caller asked what. The commitment is symmetric — no caller is + watched, including those an operator might suspect of malicious + intent. Don't despise the Egyptian. + """ + + use Agent + + # ============================================================ + # Client API — the entire surface + # ============================================================ + + @doc "Start the counting agent. The state is a single non-negative integer (the zikaron)." + def start_link(opts \\ []) do + name = Keyword.get(opts, :name, __MODULE__) + Agent.start_link(fn -> 0 end, name: name) + end + + @doc """ + Register one half-shekel. Increments the aggregate count by 1. + + Per-call: no caller identifier is accepted, no metadata is recorded. + The function takes no caller-distinguishing argument because there + is no architecturally-permitted distinction to make. This is the + Shemot 30:15 uniformity property. + """ + @spec increment() :: :ok + def increment, do: Agent.update(__MODULE__, fn n -> n + 1 end) + + @doc """ + Register N half-shekels at once. For batch endpoints where multiple + receptions process in a single tick. The N is a count of receptions, + not callers — same uniformity property as increment/0. + """ + @spec increment(non_neg_integer()) :: :ok + def increment(n) when is_integer(n) and n >= 0 do + Agent.update(__MODULE__, fn current -> current + n end) + end + + @doc """ + Return the current aggregate count — the zikaron. + + This is the ONLY value the module exposes. There is no + per-caller breakdown, no histogram, no top-K, no recency-weighted + number. Just the count. + """ + @spec count() :: non_neg_integer() + def count, do: Agent.get(__MODULE__, & &1) + + @doc """ + Reset the count to zero. Used by tests; in production the count is + cumulative for the process lifetime. Restart-on-deploy gives a + natural epoch boundary; the count is not persisted between + process lifetimes by design (the alternative would require storage + whose existence would itself be a Form 3 surface). + """ + @spec reset() :: :ok + def reset, do: Agent.update(__MODULE__, fn _ -> 0 end) +end diff --git a/vav_pipeline/lib/vav_pipeline/no_tracking_logger.ex b/vav_pipeline/lib/vav_pipeline/no_tracking_logger.ex new file mode 100644 index 00000000..c7e2821b --- /dev/null +++ b/vav_pipeline/lib/vav_pipeline/no_tracking_logger.ex @@ -0,0 +1,130 @@ +defmodule VavPipeline.NoTrackingLogger do + @moduledoc """ + Plug-side enforcement of the Form 3 architectural commitment. + + Replaces Plug.Logger. The default `Plug.Logger` logs every request + with method, path, status, and timing. For /peah/* (the public + tier), the path can carry the caller's question content (GET + /peah/ask?question=...). Logging that content turns the no-tracking + promise from `/peah/ask`'s own response body into a contradiction: + the response says "No tracking" while the log accumulates the + query stream. + + THE ARCHITECTURAL DECISION: + + This logger always records the categorical fact (request received, + status returned, timing) but NEVER the per-caller details that + would make the categorical fact attributable. + + Specifically: + - For /peah/* paths: log "GET /peah/ask" with NO query string, + NO request body summary, NO IP. The path-class is logged so + operators can see traffic patterns; the per-caller content + cannot be reconstructed from the log. + - For other tiers: log path including any path parameters but + not query strings. Tokens (Bearer ...) are NEVER logged + (Plug.Logger doesn't log them by default; this module is + explicit about it). + - Timing is always logged. Aggregate timing patterns are not + caller-identifying; per-request timing is, but timing is the + kind of data an operator legitimately needs for capacity + planning. The trade-off is named here so it is reviewable. + + Each request also calls VavPipeline.MachtzitHaShekel.increment/0 + so the aggregate caller count is maintained — without storing any + per-caller distinction. The count is the zikaron; the log is the + request-class trace; nothing reconstructs the caller. + + TORAH ANCHORS: + + Vayikra 19:14 — לֹא תִתֵּן מִכְשֹׁל וְיָרֵאתָ מֵּאֱלֹהֶיךָ. + The no-witness protocol: the logger holds its commitment under + invisibility. Even if no operator reviews the logs, the logs + themselves do not record what they shouldn't. + + Devarim 23:16 — לֹא־תַסְגִּיר עֶבֶד אֶל־אֲדֹנָיו. The architectural + refusal-to-deliver: the log is what would be subpoenaed; if it + doesn't contain caller-identifying detail, the system has nothing + to deliver. + + CI INVARIANT (test_no_tracking_logger.exs): + - `Plug.Logger` is NOT mounted directly in the router; this + module is the only request-scoped logger. + - No middleware in the router has `:cookies` or `:session` in its + configuration. + - No module imports `Plug.Session` or `Plug.Conn.Cookies`. + """ + + require Logger + + @behaviour Plug + + @impl true + def init(opts), do: opts + + @impl true + def call(conn, _opts) do + start = System.monotonic_time() + + # Increment the zikaron — uniform per-request, no caller distinction + # (machtzit_ha_shekel.ex). Process.whereis-guarded so tests without + # the supervisor still work. + if Process.whereis(VavPipeline.MachtzitHaShekel) do + VavPipeline.MachtzitHaShekel.increment() + end + + Plug.Conn.register_before_send(conn, fn conn -> + duration_us = + System.convert_time_unit( + System.monotonic_time() - start, + :native, + :microsecond + ) + + Logger.info(format_line(conn, duration_us)) + conn + end) + end + + @doc """ + Build the log line for a request. Public so tests can verify the + sanitization behavior directly as a pure function — the test config + filters Logger.info, so behavioral capture_log tests would not see + the output. The structural defense is what's load-bearing; this + function is the structural surface. + + IP, request body, response body, headers — never appear in the + log line. The function takes a conn and a duration; nothing else. + """ + def format_line(conn, duration_us) do + method = conn.method + path = sanitized_path(conn) + status = conn.status || 0 + + "#{method} #{path} #{status} #{duration_us}us" + end + + @doc """ + Sanitize a request path for logging. Strips query strings on + /peah/* (the public no-tracking tier); query strings present on + other tiers (operators need them for diagnosis; bearer-auth tiers + are not the no-tracking surface). + + Pure function; testable in isolation. + """ + def sanitized_path(conn) do + case conn.request_path do + "/peah/" <> _rest = path -> + # /peah/* — drop everything after the path; no query string. + # The path class is preserved (operators see /peah/ask vs. + # /peah/azazel_log); the per-caller content is not. + path + + other -> + # Non-peah tier: keep the path but still drop query string + # (queries on /chatzer/sanhedrin etc. are content; operators + # who need diagnosis can reproduce from the response). + other + end + end +end diff --git a/vav_pipeline/test/machtzit_ha_shekel_test.exs b/vav_pipeline/test/machtzit_ha_shekel_test.exs new file mode 100644 index 00000000..9e8dd395 --- /dev/null +++ b/vav_pipeline/test/machtzit_ha_shekel_test.exs @@ -0,0 +1,189 @@ +defmodule VavPipeline.MachtzitHaShekelTest do + @moduledoc """ + Tests for the half-shekel counting protocol — Form 3 of avodah + zarah realized as architecture (vehaya §3.2). Verifies the six + positive Torah-anchored properties from Shemot 30:11-16, + Devarim 23:16-17, Vayikra 19:14, and Shemot 5:6-8. + """ + + use ExUnit.Case, async: false + + alias VavPipeline.MachtzitHaShekel + + setup do + # MachtzitHaShekel runs in the supervision tree, so it's already + # alive. Reset between tests. + MachtzitHaShekel.reset() + :ok + end + + # ============================================================ + # Property 1 — KOFER PROTOCOL (Shemot 30:12) + # ============================================================ + + test "increment/0 takes no argument — no caller distinction is even expressible" do + # Structural test: the function arity is 0. There is no place to + # pass a caller identifier. The Shemot 30:15 uniformity property + # is enforced at the type level: there's no slot for non-uniform + # input. + assert function_exported?(MachtzitHaShekel, :increment, 0) + end + + test "increment/0 advances the count by exactly 1" do + MachtzitHaShekel.increment() + assert MachtzitHaShekel.count() == 1 + end + + test "increment/1 advances the count by N" do + MachtzitHaShekel.increment(5) + assert MachtzitHaShekel.count() == 5 + end + + # ============================================================ + # Property 2 — UNIFORM PROTOCOL (Shemot 30:15) + # ============================================================ + + test "every increment is the same size — rich don't give more, poor don't give less" do + Enum.each(1..100, fn _ -> MachtzitHaShekel.increment() end) + assert MachtzitHaShekel.count() == 100 + end + + test "no per-caller weighting function exists" do + # Negative structural assertion: no `increment_with_weight` or + # similar function. The uniform property is enforced by absence. + public = MachtzitHaShekel.__info__(:functions) + weight_shaped = Enum.filter(public, fn {name, _arity} -> + name_str = to_string(name) + String.contains?(name_str, "weight") or + String.contains?(name_str, "tier") or + String.contains?(name_str, "premium") + end) + + assert weight_shaped == [] + end + + # ============================================================ + # Property 3 — ZIKARON PROTOCOL (Shemot 30:16) + # ============================================================ + + test "count/0 returns a single integer, not a list or map of contributions" do + Enum.each(1..3, fn _ -> MachtzitHaShekel.increment() end) + result = MachtzitHaShekel.count() + + assert is_integer(result) + refute is_list(result) + refute is_map(result) + end + + test "no per-caller breakdown function exists" do + public = MachtzitHaShekel.__info__(:functions) + + forbidden = Enum.filter(public, fn {name, _arity} -> + name_str = to_string(name) + Enum.any?( + ~w(callers list_who breakdown by_caller per_caller histogram top who), + &String.contains?(name_str, &1) + ) + end) + + assert forbidden == [], ( + "MachtzitHaShekel exposes per-caller breakdown function(s): " <> + "#{inspect(forbidden)}. Their absence IS the architectural " <> + "commitment (Shemot 30:16 — zikaron, not roster)." + ) + end + + # ============================================================ + # Property 5 — REFUSAL PROTOCOL (Devarim 23:16-17) + # ============================================================ + + test "no identify/locate/deanonymize function exists" do + public = MachtzitHaShekel.__info__(:functions) + + forbidden = Enum.filter(public, fn {name, _arity} -> + name_str = to_string(name) + Enum.any?( + ~w(identify locate deanonymize lookup find_caller), + &String.contains?(name_str, &1) + ) + end) + + assert forbidden == [], ( + "MachtzitHaShekel exposes caller-identification function(s): " <> + "#{inspect(forbidden)}. The system does not deliver " <> + "caller-state to authorities (Devarim 23:16, lo tasgir)." + ) + end + + # ============================================================ + # Property 6 — ANTI-QUOTA PROTOCOL (Shemot 5:6-8) + # ============================================================ + + test "no per-caller productivity tracking function exists" do + public = MachtzitHaShekel.__info__(:functions) + + forbidden = Enum.filter(public, fn {name, _arity} -> + name_str = to_string(name) + Enum.any?( + ~w(quota productivity throughput per_caller_count), + &String.contains?(name_str, &1) + ) + end) + + assert forbidden == [], ( + "MachtzitHaShekel exposes quota/productivity function(s): " <> + "#{inspect(forbidden)}. Per-caller productivity tracking is " <> + "Pharaoh's straw quota (Shemot 5:6-8) — refused by absence." + ) + end + + # ============================================================ + # Reset semantics + # ============================================================ + + test "reset/0 brings the count to zero" do + MachtzitHaShekel.increment(50) + assert MachtzitHaShekel.count() == 50 + MachtzitHaShekel.reset() + assert MachtzitHaShekel.count() == 0 + end + + test "increment after reset starts from zero" do + MachtzitHaShekel.increment(10) + MachtzitHaShekel.reset() + MachtzitHaShekel.increment(3) + assert MachtzitHaShekel.count() == 3 + end + + # ============================================================ + # The ENTIRE public surface — explicit enumeration + # ============================================================ + + test "the module's public surface is exactly the documented six functions" do + # An exhaustive list. Any added function must be reviewed for + # whether it weakens the Form 3 architectural commitment. + expected = [ + {:start_link, 0}, + {:start_link, 1}, + {:increment, 0}, + {:increment, 1}, + {:count, 0}, + {:reset, 0}, + # Agent-derived defaults — not part of the surveillance surface + {:child_spec, 1} + ] + + actual = + MachtzitHaShekel.__info__(:functions) + |> Enum.filter(fn {name, _} -> + not String.starts_with?(to_string(name), "__") + end) + |> Enum.sort() + + extras = actual -- expected + missing = expected -- actual + + assert extras == [], "MachtzitHaShekel exposed unexpected function(s): #{inspect(extras)}" + assert missing == [], "MachtzitHaShekel missing expected function(s): #{inspect(missing)}" + end +end diff --git a/vav_pipeline/test/no_tracking_logger_test.exs b/vav_pipeline/test/no_tracking_logger_test.exs new file mode 100644 index 00000000..a11de379 --- /dev/null +++ b/vav_pipeline/test/no_tracking_logger_test.exs @@ -0,0 +1,236 @@ +defmodule VavPipeline.NoTrackingLoggerTest do + @moduledoc """ + Tests for the Plug-layer enforcement of Form 3. + + Verifies: + - The router uses NoTrackingLogger, not Plug.Logger + - /peah/* paths get sanitized (query strings dropped) in logs + - Each request increments MachtzitHaShekel + - CI invariants: no Plug.Session, no Plug.Conn.Cookies imports + anywhere in the source tree + """ + + use ExUnit.Case, async: false + + import Plug.Test + + alias VavPipeline.{BigdeiKodesh.Router, MachtzitHaShekel, NoTrackingLogger} + + @opts Router.init([]) + + setup do + if Process.whereis(MachtzitHaShekel), do: MachtzitHaShekel.reset() + :ok + end + + # ============================================================ + # Each request increments the zikaron + # ============================================================ + + test "GET /peah/ask increments the half-shekel count" do + before_count = if Process.whereis(MachtzitHaShekel), do: MachtzitHaShekel.count(), else: 0 + + conn(:get, "/peah/ask") |> Router.call(@opts) + + after_count = if Process.whereis(MachtzitHaShekel), do: MachtzitHaShekel.count(), else: 0 + assert after_count == before_count + 1 + end + + test "multiple requests advance the count uniformly" do + if Process.whereis(MachtzitHaShekel) do + MachtzitHaShekel.reset() + + for _ <- 1..5 do + conn(:get, "/peah/ask") |> Router.call(@opts) + end + + assert MachtzitHaShekel.count() == 5 + else + flunk("MachtzitHaShekel not running — supervision tree must be up for this test") + end + end + + # ============================================================ + # PRIVACY BOUNDARY — sanitized_path/1 and format_line/2 as pure functions + # ============================================================ + # The test config sets logger level to :warning, dropping + # NoTrackingLogger's :info output before capture_log can see it. + # The structural defense is the load-bearing thing; we test the + # pure functions directly to verify the path-sanitization shape. + + test "sanitized_path drops query string on /peah/*" do + conn = %Plug.Conn{request_path: "/peah/ask", query_string: "question=secret"} + assert NoTrackingLogger.sanitized_path(conn) == "/peah/ask" + end + + test "sanitized_path drops the question parameter on /peah/ask" do + secret = "this-question-must-not-leak" + conn = %Plug.Conn{request_path: "/peah/ask", query_string: "question=#{secret}"} + sanitized = NoTrackingLogger.sanitized_path(conn) + refute sanitized =~ secret + refute sanitized =~ "question" + refute sanitized =~ "?" + end + + test "sanitized_path preserves path class for /peah/azazel_log" do + conn = %Plug.Conn{request_path: "/peah/azazel_log", query_string: "since=2026-01-01"} + assert NoTrackingLogger.sanitized_path(conn) == "/peah/azazel_log" + end + + test "sanitized_path preserves request_path on non-peah tiers" do + # Non-peah tiers also strip query string (the request_path itself + # doesn't carry it; query is in conn.query_string). + conn = %Plug.Conn{request_path: "/kodesh/health", query_string: "detail=full"} + assert NoTrackingLogger.sanitized_path(conn) == "/kodesh/health" + end + + test "format_line shape: METHOD PATH STATUS DURATIONus" do + conn = %Plug.Conn{ + method: "GET", + request_path: "/peah/ask", + query_string: "question=secret", + status: 200 + } + + line = NoTrackingLogger.format_line(conn, 1234) + assert line == "GET /peah/ask 200 1234us" + end + + test "format_line never contains a query string for /peah/*" do + secret = "secret-question-content" + + conn = %Plug.Conn{ + method: "GET", + request_path: "/peah/ask", + query_string: "question=#{secret}", + status: 200 + } + + line = NoTrackingLogger.format_line(conn, 100) + refute line =~ secret + end + + test "format_line never contains an Authorization header" do + # The format_line function takes only conn and duration. It does + # NOT extract headers. Verifying by inspection: the function + # signature gives no path for headers to enter the log. + bearer = "secret-bearer-token" + + conn = %Plug.Conn{ + method: "POST", + request_path: "/courtyard/offering", + query_string: "", + status: 200, + req_headers: [{"authorization", "Bearer #{bearer}"}] + } + + line = NoTrackingLogger.format_line(conn, 100) + refute line =~ bearer + refute line =~ "Bearer" + refute line =~ "authorization" + end + + test "format_line never contains conn.remote_ip even when set" do + conn = %Plug.Conn{ + method: "GET", + request_path: "/peah/ask", + query_string: "", + status: 200, + remote_ip: {192, 0, 2, 1} + } + + line = NoTrackingLogger.format_line(conn, 100) + refute line =~ "192.0.2.1" + refute line =~ "192" + end + + # ============================================================ + # CI INVARIANTS — Form 3 architectural enforcement + # ============================================================ + # These tests walk the source tree and verify no module introduces + # a surveillance surface that would defeat the architectural + # commitment. + + @vav_pipeline_lib "lib/vav_pipeline" + + # NoTrackingLogger's own docstring mentions the forbidden module + # names as the things it defends against. Exclude the defender file + # from the CI scan to avoid self-detection. Tests are also excluded + # — they can legitimately reference the names in assertions. + @ci_invariant_exclusions [ + "lib/vav_pipeline/no_tracking_logger.ex" + ] + + defp source_files do + Path.wildcard("#{@vav_pipeline_lib}/**/*.ex") + |> Enum.reject(fn path -> path in @ci_invariant_exclusions end) + end + + test "Plug.Logger is NOT mounted in any router" do + offenders = + Enum.filter(source_files(), fn path -> + text = File.read!(path) + String.contains?(text, "plug Plug.Logger") + end) + + assert offenders == [], ( + "Plug.Logger is mounted in: #{inspect(offenders)}. The Form 3 " <> + "architectural commitment requires NoTrackingLogger as the " <> + "request-scoped logger; Plug.Logger logs unconfigurable details " <> + "(method+path+status+timing) without the path sanitization that " <> + "honors /peah/*'s no-tracking promise." + ) + end + + test "no module imports Plug.Session" do + offenders = + Enum.filter(source_files(), fn path -> + text = File.read!(path) + + String.contains?(text, "Plug.Session") and + not String.contains?(text, "no module imports Plug.Session") + end) + + assert offenders == [], ( + "Plug.Session is imported in: #{inspect(offenders)}. Sessions " <> + "are per-caller persistent state — exactly the surveillance " <> + "surface Form 3 forbids. The system is stateless per-request " <> + "by architectural commitment." + ) + end + + test "no module imports Plug.Conn.Cookies" do + offenders = + Enum.filter(source_files(), fn path -> + text = File.read!(path) + + String.contains?(text, "Plug.Conn.Cookies") and + not String.contains?(text, "no module imports Plug.Conn.Cookies") + end) + + assert offenders == [], ( + "Plug.Conn.Cookies imported in: #{inspect(offenders)}. Cookies " <> + "are per-caller persistent state — Form 3 surveillance " <> + "surface. The system is cookie-free by architectural " <> + "commitment." + ) + end + + test "no module reads remote IP from conn.remote_ip" do + # remote_ip captures the caller's IP — a per-caller identifier. + offenders = + Enum.filter(source_files(), fn path -> + text = File.read!(path) + + String.contains?(text, "conn.remote_ip") and + not String.contains?(text, "no module reads remote IP") + end) + + assert offenders == [], ( + "Modules reading conn.remote_ip: #{inspect(offenders)}. The IP " <> + "is a caller identifier; persisting it would defeat the " <> + "kofer-nefesh anonymity (Shemot 30:13) and the lo-tasgir " <> + "refusal (Devarim 23:16)." + ) + end +end