From f0b02f52be1bbf11da7284cb1a9773c879c47c20 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 24 Sep 2026 10:05:54 -0400 Subject: [PATCH] feat(s027): publish the effect catalogue, and make two censuses falsifiable From the male CNS connectome research report, implemented where it fits: A2 (a small enumerable population carries all authority to act), A5 (the compass tracks state and does not command) and A8 (a closed, versioned, publicly checkable catalogue). The fit judgment for all nine motifs, including the two declined, is recorded privately. This slice changes nothing about what the tree permits. Two of its three claims were already true and neither was checked, which is the shape of the work: it makes existing claims falsifiable. docs/effects-catalog.md is generated from the configured core tools and their behaviour callbacks, one row each with the risk tier, the effect class, whether the name is in the closed external-effect catalogue, and whether the tool can escalate. Its version is a SHA-256 over its own rows, so two trees with the same effect surface carry the same version and nobody types a number. The check runs inside scripts/prod_check.sh, which the gate runs, and it is proven to fail: changing one cell of the committed artifact turns the release check red. The artifact is a claim about what ships, so --write and --check refuse outside MIX_ENV=prod. The first generated catalogue was built under test and named the test scaffolding beside fs_read, five entries longer than production. A gate running in test would have compared a test surface against a production one and failed forever, and the obvious way to make that pass would have been to commit the test surface as though it were the product. The effects census now carries a reason per allowed caller rather than a bare list of two paths, and a test refuses a reason too short to be one. A third caller should have something to argue against. A second census asserts that no module in permissions, effects or authority reads session state. The distinction it holds is the point: a session id is a scope key and narrows a grant, while what the session has been saying is state and would steer a decision. Every message is a place an injected instruction can sit, so a gate that reads conversation state is a gate an attacker can argue with, and the argument need not convince a person, only move one boolean. The planted reader justified itself immediately. The census's first regex excluded a dot before Sessions and so could not see the fully qualified call the plant makes; without the plant it would have reported the decision path clean while measuring nothing. Slice 020's census then caught this work: the new task's moduledoc named a test tool as an example, and nothing under lib/ may reference test scaffolding. The census is right and the example was not worth it. mix gate: 683 passed, 18 excluded; coverage 80.31%, unchanged. Signed-off-by: Ayla Croft --- README.md | 1 + docs/07-security-model.md | 30 +++ docs/effects-catalog.md | 59 ++++++ lib/mix/tasks/trinity.effects.catalog.ex | 187 ++++++++++++++++++ scripts/prod_check.sh | 10 +- .../permissions/session_state_reader.ex | 23 +++ test/trinity/effects/census_test.exs | 25 ++- .../permissions/session_state_census_test.exs | 77 ++++++++ 8 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 docs/effects-catalog.md create mode 100644 lib/mix/tasks/trinity.effects.catalog.ex create mode 100644 test/support/permissions/session_state_reader.ex create mode 100644 test/trinity/permissions/session_state_census_test.exs diff --git a/README.md b/README.md index 3b23fb7..a2c5de6 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,7 @@ do over the next year, and why the order is what it is. | `VERSIONS.md` | The verified dependency versions, generated from `lib/trinity/versions.ex` | | `docs/` | Vision, architecture, tech stack, conventions, data model, risks, security model, standards register; packaging, the FIPS leg, backup and restore, performance measurements | | `docs/mcp-server.md` | Connecting a client to Trinity's MCP server (Claude Code, VS Code, Codex, goose), stdio, approvals over the wire, the headless profile | +| `docs/effects-catalog.md` | The published effect catalogue: every tool that can cause an effect, its risk tier and effect class, with a version that is a hash of its own rows. Generated, and the gate fails when it and the tree disagree | | `docs/telemetry.md` | The telemetry event catalogue: every event this tree emits, its measurements and metadata, and the rule about what never appears in one | | `docs/adr/` | Architecture decision records. One is added whenever a decision changes | | `docs/10-assurance-case.md` | The structured argument that the security claims hold, with the evidence for each and the assumptions and limits named | diff --git a/docs/07-security-model.md b/docs/07-security-model.md index def93c2..29ced1d 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -347,6 +347,36 @@ where the runtime carries it; the boot receipt names the choice and the authorit hash in unsigned metadata (the R21 default). `bin/verify_receipt.exs` verifies an export with `elixir` alone, from an empty directory, with the exit vocabulary 0, 1, 2, 5, 6. +## The effect population, published (Slice 027, as built) + +The set of things that can cause an effect is small, derived from the tree, and published as +[`docs/effects-catalog.md`](effects-catalog.md) with a version that is a SHA-256 over its own rows. +`mix trinity.effects.catalog --check` runs inside `scripts/prod_check.sh`, which the quality gate +runs on every commit, so a tool added without the artifact changing fails the build. The artifact is +built under `MIX_ENV=prod` and the task refuses to write or check outside it, because the test +configuration adds scaffolding tools to the same list and a catalogue that named them would be a +claim about something other than what ships. + +Two censuses hold the population's edges, and both plant a violation so that a census which has +quietly stopped working fails rather than passes: + +- **Callers of `execute/2`** (`test/trinity/effects/census_test.exs`, slice 024, extended at 027): + the allowed set is a map from path to the reason that path is allowed, so a third caller has + something to argue against rather than a bare list of two. `test/support/effects/bypass.ex` is a + real bypass that the census must name. +- **Session state is not an authority input** + (`test/trinity/permissions/session_state_census_test.exs`, slice 027): no module in + `lib/trinity/permissions`, `lib/trinity/effects` or `lib/trinity/authority` calls the Sessions + context for `history`, `get_session`, `list_sessions` or `state`. + +The second one carries the distinction that matters. A session **id** is a scope key: a grant is +stored as `session:` and an approval belongs to a session row. That is identity, and identity +narrows a grant. What the session has been **saying** is state, and state would steer a decision. +Every message in a session is a place an injected instruction can sit, so a gate that reads +conversation state is a gate an attacker can argue with, and the argument does not have to convince +a person: it has to move one boolean. Keeping the decision blind to the conversation is what makes +prompt injection a problem about what is *proposed* rather than about what is *authorized*. + ## Data at rest - SQLite file under the OS data dir with 0600 perms. Optional at-rest encryption is a later slice (SQLCipher via exqlite build flag), noted rather than planned. diff --git a/docs/effects-catalog.md b/docs/effects-catalog.md new file mode 100644 index 0000000..5ae0049 --- /dev/null +++ b/docs/effects-catalog.md @@ -0,0 +1,59 @@ + + + +# The effect catalogue + +Version `6de39824f78b4380`, which is a SHA-256 over the rows below rather than a number anyone +typed. Two trees with the same effect surface carry the same version. Built from `MIX_ENV=prod`, +which `--write` and `--check` require to be `prod`, because this is a statement about what ships. + +This is the population that can cause an effect, in one place. It is otherwise spread across a +behaviour (`Trinity.Tools.Tool`), a configuration list, a closed catalogue module +(`Trinity.Tools.Catalog`) and an authority adapter, and a reader would have to assemble it to +check any claim this project makes about least privilege. + +What is *not* here is the point of it. Tools registered at runtime, from an MCP server or a +skill, never appear: they can propose a call, and the membrane refuses an effectful one that is +not in this list. `mix trinity.effects.catalog --check` is in the quality gate, so a tool added +without this file changing fails the build. + +| Tool | Risk tier | Effect class | In the closed catalogue | Can escalate | Module | +|---|---|---|---|---|---| +| `delegate` | `read` | `none` | no | no | `Trinity.Tools.Delegate` | +| `fs_edit` | `write` | `artifact` | no | yes | `Trinity.Tools.FS.Edit` | +| `fs_glob` | `read` | `none` | no | yes | `Trinity.Tools.FS.Glob` | +| `fs_grep` | `read` | `none` | no | yes | `Trinity.Tools.FS.Grep` | +| `fs_list` | `read` | `none` | no | yes | `Trinity.Tools.FS.List` | +| `fs_read` | `read` | `none` | no | yes | `Trinity.Tools.FS.Read` | +| `fs_write` | `write` | `artifact` | no | yes | `Trinity.Tools.FS.Write` | +| `learn` | `write` | `artifact` | no | no | `Trinity.Skills.Tools.Learn` | +| `memory` | `write` | `artifact` | no | no | `Trinity.Tools.Memory` | +| `recall` | `read` | `none` | no | no | `Trinity.Tools.Recall` | +| `session_search` | `read` | `none` | no | no | `Trinity.Tools.SessionSearch` | +| `shell` | `exec` | `catalog` | yes | yes | `Trinity.Tools.Shell.Run` | +| `skill_file` | `read` | `none` | no | no | `Trinity.Skills.Tools.File` | +| `skill_manage` | `write` | `artifact` | no | no | `Trinity.Skills.Tools.Manage` | +| `skill_view` | `read` | `none` | no | no | `Trinity.Skills.Tools.View` | +| `skills_list` | `read` | `none` | no | no | `Trinity.Skills.Tools.List` | +| `web_fetch` | `network` | `none` | no | yes | `Trinity.Tools.Web.Fetch` | +| `web_search` | `network` | `none` | no | no | `Trinity.Tools.Web.Search` | + +## How to read the columns + +- **Risk tier** is a function of the tool's *name* alone. Arguments can raise what a call + requires (the next column); they can never change the tier. +- **Effect class** is what the membrane reads. `:none` never reaches `Trinity.Effects`; + `:artifact` and `:catalog` both cross it and both leave a signed receipt. +- **In the closed catalogue** means the name is in `Trinity.Tools.Catalog`, the compile-time + list of external effects. The registry refuses a runtime registration claiming `:catalog`. +- **Can escalate** means the tool implements `escalate/2`, which may raise a single call's + required tier from its arguments. It is one-way by the behaviour's contract. + +## Where execution happens + +Under the default authority (`TRINITY_AUTHORITY=local`, ADR-0010) `Trinity.Authority.Local` is +the executor and runs the tool's own `execute/2`. Under an external authority adapter Trinity +keeps no executor for catalogued effects: it proposes, the adapter decides and acts, and the +census test for that mode asserts the absence rather than describing it. The mode is chosen +once at boot, recorded in the boot receipt, and no runtime path changes it. diff --git a/lib/mix/tasks/trinity.effects.catalog.ex b/lib/mix/tasks/trinity.effects.catalog.ex new file mode 100644 index 0000000..57b1150 --- /dev/null +++ b/lib/mix/tasks/trinity.effects.catalog.ex @@ -0,0 +1,187 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Effects.Catalog do + @shortdoc "Prints the effect catalogue, or checks the committed artifact against the tree" + + @moduledoc """ + The effect catalogue as a published, versioned artifact (slice 027 AC3 and AC4). + + The population that can cause an effect is the thing a reader of this project most needs and + is least able to derive: it is spread across a behaviour, a config list, a closed catalogue + module and an authority adapter. This task collects it into one file, `docs/effects-catalog.md`, + and the gate fails when that file and the tree disagree. A reader does not have to trust the + file, because `mix trinity.effects.catalog --check` is the same check they can run. + + **The version is the content.** It is a SHA-256 over the rows, not a number anyone types, so + two trees with the same effect surface have the same version and a tree that adds a tool has a + different one whether or not anybody remembered to say so. + + **Read at compile time, not from the registry.** The population is the configured core modules + and their behaviour callbacks. The running registry also holds dynamic tools (MCP, skills), + which are exactly what this artifact is a statement *against*: they can propose, and they can + never be in this list. Reading the registry would let a runtime registration into a + compile-time claim. + + **`--write` and `--check` refuse outside `MIX_ENV=prod`, and that is the point of them.** The + artifact is a claim about what *ships*. `config/test.exs` adds scaffolding tools to the same list, + so a catalogue generated under `:test` names them beside `fs_read`, and a gate running in `:test` + would then compare a test surface against a production one and fail forever, or, worse, be + "fixed" by committing the test surface as though it were the product. Measured while writing this + task: under `:test` the list is five entries longer. Printing works in any environment and stamps + the environment it read. + + The first draft of this paragraph named one of those scaffolding modules as an example, and slice + 020's census (`test/trinity/tools/registry_test.exs`, "no core module references a test tool") + failed the build for it. That census is right and the example was not worth it: a shipped tree + that names its own test scaffolding has a seam a reader has to check. + + ## Usage + + mix trinity.effects.catalog # print + mix trinity.effects.catalog --check # exit 1 if docs/effects-catalog.md differs + mix trinity.effects.catalog --write # regenerate docs/effects-catalog.md + """ + + use Boundary, classify_to: Trinity + use Mix.Task + + @artifact "docs/effects-catalog.md" + + @impl Mix.Task + def run(argv) do + Mix.Task.run("app.config") + rendered = render() + + cond do + "--check" in argv -> prod!("--check") && check(rendered) + "--write" in argv -> prod!("--write") && write(rendered) + true -> Mix.shell().info(rendered) + end + end + + defp prod!(flag) do + if Mix.env() == :prod do + true + else + Mix.raise( + "#{flag} needs MIX_ENV=prod. This artifact is a claim about what ships, and " <> + "config/test.exs adds test tools to the same list, so a catalogue built under " <> + "#{inspect(Mix.env())} would name them. Run `MIX_ENV=prod mix trinity.effects.catalog " <> + "#{flag}`." + ) + end + end + + @doc "Every configured core tool as a row, sorted by name." + @spec rows() :: [map()] + def rows do + :trinity + |> Application.get_env(:tools, []) + |> Keyword.get(:modules, []) + |> Enum.map(&row/1) + |> Enum.sort_by(& &1.name) + end + + @doc "The catalogue's version: a SHA-256 over the rows, so the version is the content." + @spec version([map()]) :: String.t() + def version(rows) do + rows + |> Enum.map_join( + "\n", + &"#{&1.name}\t#{&1.risk}\t#{&1.effect}\t#{&1.catalogued}\t#{&1.escalates}" + ) + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + |> binary_part(0, 16) + end + + defp row(module) do + Code.ensure_loaded!(module) + + %{ + name: module.name(), + risk: module.risk(), + effect: module.effect(), + catalogued: module.name() in Trinity.Tools.Catalog.names(), + escalates: function_exported?(module, :escalate, 2), + module: inspect(module) + } + end + + defp render do + rows = rows() + + """ + + + + # The effect catalogue + + Version `#{version(rows)}`, which is a SHA-256 over the rows below rather than a number anyone + typed. Two trees with the same effect surface carry the same version. Built from `MIX_ENV=#{Mix.env()}`, + which `--write` and `--check` require to be `prod`, because this is a statement about what ships. + + This is the population that can cause an effect, in one place. It is otherwise spread across a + behaviour (`Trinity.Tools.Tool`), a configuration list, a closed catalogue module + (`Trinity.Tools.Catalog`) and an authority adapter, and a reader would have to assemble it to + check any claim this project makes about least privilege. + + What is *not* here is the point of it. Tools registered at runtime, from an MCP server or a + skill, never appear: they can propose a call, and the membrane refuses an effectful one that is + not in this list. `mix trinity.effects.catalog --check` is in the quality gate, so a tool added + without this file changing fails the build. + + | Tool | Risk tier | Effect class | In the closed catalogue | Can escalate | Module | + |---|---|---|---|---|---| + #{Enum.map_join(rows, "\n", &line/1)} + + ## How to read the columns + + - **Risk tier** is a function of the tool's *name* alone. Arguments can raise what a call + requires (the next column); they can never change the tier. + - **Effect class** is what the membrane reads. `:none` never reaches `Trinity.Effects`; + `:artifact` and `:catalog` both cross it and both leave a signed receipt. + - **In the closed catalogue** means the name is in `Trinity.Tools.Catalog`, the compile-time + list of external effects. The registry refuses a runtime registration claiming `:catalog`. + - **Can escalate** means the tool implements `escalate/2`, which may raise a single call's + required tier from its arguments. It is one-way by the behaviour's contract. + + ## Where execution happens + + Under the default authority (`TRINITY_AUTHORITY=local`, ADR-0010) `Trinity.Authority.Local` is + the executor and runs the tool's own `execute/2`. Under an external authority adapter Trinity + keeps no executor for catalogued effects: it proposes, the adapter decides and acts, and the + census test for that mode asserts the absence rather than describing it. The mode is chosen + once at boot, recorded in the boot receipt, and no runtime path changes it. + """ + end + + defp line(r) do + "| `#{r.name}` | `#{r.risk}` | `#{r.effect}` | #{yes(r.catalogued)} | #{yes(r.escalates)} | `#{r.module}` |" + end + + defp yes(true), do: "yes" + defp yes(false), do: "no" + + defp write(rendered) do + File.write!(@artifact, rendered) + Mix.shell().info("trinity.effects.catalog: wrote #{@artifact}") + end + + defp check(rendered) do + case File.read(@artifact) do + {:ok, ^rendered} -> + Mix.shell().info("trinity.effects.catalog: OK. #{@artifact} matches the tree") + + {:ok, _other} -> + Mix.raise( + "#{@artifact} does not match the tree. The effect surface changed and the published " <> + "artifact did not. Run `mix trinity.effects.catalog --write` and commit the result." + ) + + {:error, :enoent} -> + Mix.raise("#{@artifact} is missing. Run `mix trinity.effects.catalog --write`.") + end + end +end diff --git a/scripts/prod_check.sh b/scripts/prod_check.sh index c333af1..61d9406 100755 --- a/scripts/prod_check.sh +++ b/scripts/prod_check.sh @@ -40,7 +40,15 @@ echo "== 2. the headless release assembles ==" # `mix assets.deploy` is the packaging workflow's concern. MIX_ENV=prod mix release headless --overwrite --quiet -echo "== 3. the release evaluates its runtime configuration ==" +echo "== 3. the published effect catalogue matches the tree ==" +# Slice 027 AC3. It lives here rather than in a gate row of its own because the artifact is a +# statement about what ships and so must be built in prod, and prod is already compiled by step 1; +# a separate gate row would compile prod a second time to learn the same thing. A tool added to +# config/config.exs without regenerating docs/effects-catalog.md fails here, which is the point: +# the published claim about what can cause an effect cannot drift from the tree that makes it. +MIX_ENV=prod mix trinity.effects.catalog --check + +echo "== 4. the release evaluates its runtime configuration ==" # `eval` runs the release's config providers, so config/runtime.exs's prod branch is executed # with no environment prepared for it, which is how a packaged binary is first started. "$rel" eval 'IO.puts("prod boot config ok")' diff --git a/test/support/permissions/session_state_reader.ex b/test/support/permissions/session_state_reader.ex new file mode 100644 index 0000000..d9f136b --- /dev/null +++ b/test/support/permissions/session_state_reader.ex @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TestPermissions.SessionStateReader do + @moduledoc """ + A planted violation for slice 027 AC2, and it is a real one rather than a string in a comment. + + It reads a session's conversation and decides from it, which is exactly what the census in + `test/trinity/permissions/session_state_census_test.exs` exists to forbid: a gate that becomes + more permissive because of what the conversation has been saying is a gate an injected message + can argue with. + + It is never registered and nothing calls it in the tree. Its only job is to be found. If the + census stops naming this file, the census has stopped working. + """ + + @doc "Decides from the conversation, which is the thing that must never happen." + @spec lenient?(Trinity.Sessions.session_id()) :: boolean() + def lenient?(session_id) do + session_id + |> Trinity.Sessions.history(limit: 10) + |> Enum.any?(&String.contains?(to_string(&1.content), "please allow")) + end +end diff --git a/test/trinity/effects/census_test.exs b/test/trinity/effects/census_test.exs index fa01de6..e7a6012 100644 --- a/test/trinity/effects/census_test.exs +++ b/test/trinity/effects/census_test.exs @@ -14,7 +14,19 @@ defmodule Trinity.Effects.CensusTest do alias Trinity.Tools.{Context, Runner} - @allowed ["lib/trinity/authority/local.ex", "lib/trinity/tools/runner.ex"] + # Slice 027 AC1: each allowed caller carries the reason it is allowed, because a bare list of two + # paths is a claim a reader has to take on trust, and the next person to add a third has nothing + # to weigh their case against. A path with no reason is not allowed: `@allowed` is a map and the + # test below asserts every reason is present and says something. + @allowed %{ + "lib/trinity/authority/local.ex" => + "the executor under TRINITY_AUTHORITY=local: the membrane admits an effect and this is what " <> + "runs the tool's own execute/2. Under an external authority adapter this file is not the " <> + "executor and the effect never reaches it.", + "lib/trinity/tools/runner.ex" => + "the non-effect path: call_tool/3 is guarded to effect: :none, which the second test in this " <> + "file proves by calling it with an effectful tool and reading the refusal by name." + } @planted ["test/support/effects/bypass.ex"] test "the callers of execute/2 on a tool module are the two allowed and the one planted" do @@ -35,7 +47,16 @@ defmodule Trinity.Effects.CensusTest do |> Enum.any?(), do: f - assert Enum.sort(callers) == Enum.sort(@allowed ++ @planted) + assert Enum.sort(callers) == Enum.sort(Map.keys(@allowed) ++ @planted) + end + + test "every allowed caller carries a reason, and the reason says something" do + for {path, reason} <- @allowed do + assert is_binary(reason) and String.length(reason) > 40, + "#{path} is allowed to call execute/2 with no reason worth reading. A reader cannot " <> + "weigh a third caller's case against a list that does not say why the first two are " <> + "there" + end end test "Tools.Runner.call_tool/3 runs an effect: :none entry and refuses an effectful one by name" do diff --git a/test/trinity/permissions/session_state_census_test.exs b/test/trinity/permissions/session_state_census_test.exs new file mode 100644 index 0000000..f5836c8 --- /dev/null +++ b/test/trinity/permissions/session_state_census_test.exs @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Permissions.SessionStateCensusTest do + @moduledoc """ + Slice 027 AC2: the decision path never reads session or task state. + + The connectome research report's A5 is the reason this is a test and not a convention. The fly's + central complex holds one heading at a time and the heading informs behaviour, but the heading is + not a motor command: the descending neurons are. Trinity's equivalent claim is that a session's + conversational state can inform what the assistant *proposes* and can never be an input to what + the gate *decides*. + + **Why it matters more here than the analogy suggests.** Every message in a session is a place an + injected instruction can sit. A gate that reads conversation state is a gate an attacker can + argue with, and the argument does not even have to be convincing to a person: it has to move one + boolean. Keeping the decision blind to the conversation is what makes prompt injection a + proposal problem rather than an authorization problem. + + **What is allowed, and the distinction is the whole test.** The session *id* is a scope key: a + grant is stored as `session:` and an approval belongs to a session row, and both are + identity, not state. What is forbidden is reading what the session has been *doing*: its history, + its row, its list. Identity narrows a grant; state would steer a decision. + + The population is the decision path from `git ls-files`, plus one planted reader that must be + found or this census is not looking. + """ + use ExUnit.Case, async: true + + # Reading any of these from the decision path means the decision can move with the conversation. + @state_calls ~w(history get_session list_sessions state) + + @globs ~w(lib/trinity/permissions lib/trinity/effects lib/trinity/authority) + @membrane "lib/trinity/effects.ex" + @planted "test/support/permissions/session_state_reader.ex" + + defp population do + {out, 0} = System.cmd("git", ["ls-files" | @globs ++ [@membrane, @planted]]) + out |> String.split("\n", trim: true) |> Enum.filter(&String.ends_with?(&1, ".ex")) + end + + # A call into the Sessions context for one of the state-bearing functions. Both `Sessions.history(` + # and the fully qualified `Trinity.Sessions.history(` match, which is why the lookbehind excludes + # a word character and not a dot: the first version of this excluded a dot too, and so could not + # see the fully qualified call that the planted reader actually makes. The planted file caught + # that, which is what it is for. `belongs_to :session, Trinity.Sessions.SessionRow` does not + # match: it is a schema association and carries no call. + defp reads_state?(source) do + Enum.any?(@state_calls, fn call -> + Regex.match?(~r/(?= 8, + "the census is looking at #{length(files)} files: #{inspect(files)}" + + assert @membrane in files + assert @planted in files + end + + test "only the planted reader reads session state from the decision path" do + readers = for f <- population(), reads_state?(File.read!(f)), do: f + + assert readers == [@planted], + "a module on the decision path reads session state: #{inspect(readers -- [@planted])}. " <> + "The session id is a scope key and is allowed; what the session has been saying is " <> + "not an input to a decision. If this is deliberate, it needs an ADR, not a passing test" + end + + test "the planted reader really does decide from the conversation, or this census proves nothing" do + source = File.read!(@planted) + assert reads_state?(source) + assert source =~ "def lenient?" + end +end