From 201f3417a7f4fe8d2e0a3d5292b37bfa60c9f3d6 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 19:34:49 -0400 Subject: [PATCH 01/10] docs(s070): G1 plan; the slice chosen and why; the milestone field corrected to M5b Reaches Signed-off-by: Ayla Croft --- ROADMAP.md | 2 +- slices/070-gateway-core/NOTES.md | 75 ++++++++++++++++++++++++++++++++ slices/070-gateway-core/SLICE.md | 2 +- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 slices/070-gateway-core/NOTES.md diff --git a/ROADMAP.md b/ROADMAP.md index a30926b..4082893 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,7 +56,7 @@ standards register names the rows that ask for them. | 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | approved | | 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | approved | | 062 | MCP authorization: OAuth client role, resource server, personal-profile authorization server (EMA removed 2026-09-22) | 6 MCP | L | 061 | approved | -| 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | planned | +| 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | in_progress | | 071 | Gateway: Telegram | 7 Gateways | S | 070 | planned | | 072 | Gateway: Discord (Nostrum) | 7 Gateways | S | 070 | planned | | 080 | Subagents + delegation | 8 Orchestration | M | 020, 023 | planned | diff --git a/slices/070-gateway-core/NOTES.md b/slices/070-gateway-core/NOTES.md new file mode 100644 index 0000000..99e8e23 --- /dev/null +++ b/slices/070-gateway-core/NOTES.md @@ -0,0 +1,75 @@ +# Slice 070: NOTES + +## Read before code, 2026-09-22 + +Tree at `8b31ee5` on `main` (062 approved; **M5a Automates reached**; 012, the dependency, +approved since 2026-09-20). Chosen from the ready set by the coding agent on the owner's +instruction to pick, and why it rather than the others: 080 and 090 also have approved +dependencies, but 070 unblocks two slices (071, 072) where 080 unblocks one optional slice, and +090's own goal instruments "gateway event" telemetry, which does not exist until this slice +builds it. So the ROADMAP's own text implies 070 before 090. 070 also needs nothing of the +owner to start: every acceptance criterion runs against the in-process `Console` adapter, and the +real platforms are 071 and 072. + +**Correction in this commit.** `SLICE.md`'s milestone field read `M5 Always-on`, the name that was +split into M5a and M5b on 2026-09-08; slices 059 to 062 were corrected then and 070 was missed. +It now reads `M5b Reaches`, matching ROADMAP's milestone table. No scope changes with it. + +What this slice joins, measured today rather than remembered: sessions carry `origin` and +`origin_ref` already (010's migration), so the router needs no migration but its own +(`gateway_identities`); the stream is `Trinity.Sessions.Events`, topic `session:`, events +`{:user_message, _}`, `{:assistant_delta, _}`, `{:assistant_message, _}`, `{:tool_call, _}`, +`{:state, _}`, `{:turn_interrupted, _}`, `{:error, _}`, `{:compaction, _}`, `{:forked, _}`; +approvals broadcast on `approvals:` and `approvals:all` (021), and the only way to +decide one is `Trinity.Permissions.decide_request/3`; 050's delivery is a behaviour with +`deliver(Run.t(), Task.t())` and one implementation (`Delivery.Desktop`). `hammer` is **not** a +dependency and this slice does not add one: the rate limit is a token bucket in the router's own +state, so VERSIONS.md gains no row (AC6 measures the behaviour, not the library). + +The security model already decides the question this slice would otherwise have to ask: docs/07 +"Gateways" fixes the channel trust cap (`:read`, `:network`, `:write` approvable from a paired +gateway; `:exec` and `:destructive` desktop or console only, the cap applied **after** the gate's +own decision, never instead of it) and says a capped request is answered, not dropped. AC8 is that +paragraph as a test. + +## G1 plan, 2026-09-22 + +Branch `slice/070-gateway-core`; ROADMAP row 070 to `in_progress`. Each line names its test. + +1. `Trinity.Gateways` (a boundary whose deps follow docs/01's row and `Trinity.Scheduler`'s + shape: `[Trinity, Trinity.Sessions]`, with Permissions and PubSub reached through `Trinity`'s + exports), `Gateways.Adapter` (the behaviour: + `child_spec/1`, `capabilities/0`, `deliver/2`, `format/2`, `render_approval/2`) and + `Gateways.Console` (in-process adapter: a mailbox a test reads). Test: the behaviour's + callbacks are all implemented by Console, and `capabilities/0` drives chunking. +2. Migration `gateway_identities` (adapter, external_user_id, state, paired_at, allowlisted) with + `Gateways.Identities`; `Gateways.Pairing` (a one-time code, ten minutes, shown in the UI). + Tests: an unknown identity gets the pairing prompt and nothing else; a used or expired code is + refused (AC2's automatic half). +3. `Gateways.Router`: inbound tuple → identity check → rate limit → session lookup or create with + `origin` and `origin_ref` → `Sessions.send_user_message/2`; a per-conversation subscriber to + `session:` that coalesces `{:assistant_delta, _}` into an edit or a final message per the + adapter's capabilities. Tests: AC1 (streamed reply, `origin: "console"`), AC6 (the bucket), + AC7 (50 conversations under the fake provider, then `integrity_check`). +4. `Gateways.Commands` (a registry: `/new`, `/sessions`, `/model`, `/skills`, `/memory`, + `/approve`, `/deny`, `/attach`, `/help`). Tests: AC3 (`/attach` makes the desktop LiveView and + the console see one stream, both subscribed in the test), and each command's refusal path. +5. Approvals over chat: the router renders `approvals:` into the conversation and + `/approve` and `/deny` call `Permissions.decide_request/3`, **after** `Gateways.Cap` refuses a + tier the channel may not approve. Tests: AC4 (an approval mid-turn resumes on `/approve`) and + AC8 (a `:destructive` request is refused with the reason, the conversation is told where to + decide, and the refusal is receipted). +6. `Trinity.Scheduler.Delivery.Gateway` (050's behaviour, second implementation). Test: AC5, a + cron run delivered to a console conversation. +7. `/gateways` LiveView (adapter status, pairing codes, identities with allow and revoke) and + `mix trinity.console` for a dev REPL. Tests: the page lists, pairs and revokes. +8. Docs: docs/01 (the boundary and its modules), docs/07 (the Gateways section as built, the cap + as a property rather than a plan), README's "Not there yet" line; PROOF. + +Manual verification queue, so the owner sees it now and not at review: **AC2** (the pairing +prompt, then the code entered in the UI, as a screenshot) and **AC9** (the `/gateways` page). +Both are screenshots this machine can take with the cached headless chromium, as slices 013 to +060 did; neither needs an external account, a platform token or anything of the owner's. + +Not built here: the real platform adapters (071, 072), voice transcription, media beyond images, +and any change to the gate's own decision (the cap is applied after it, never instead of it). diff --git a/slices/070-gateway-core/SLICE.md b/slices/070-gateway-core/SLICE.md index 5de1896..d08c069 100644 --- a/slices/070-gateway-core/SLICE.md +++ b/slices/070-gateway-core/SLICE.md @@ -3,7 +3,7 @@ | Field | Value | |---|---| | Phase | 7 Gateways | -| Milestone | M5 Always-on | +| Milestone | M5b Reaches | | Size | M | | Depends on | 012 | From 49bcbc68a1a503c5fed17b8049aff0edf1937245 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 20:10:16 -0400 Subject: [PATCH 02/10] feat(s070): the gateway boundary, the adapter behaviour, the shared formatting and the in-process console adapter Signed-off-by: Ayla Croft --- lib/trinity/gateways.ex | 22 +++++ lib/trinity/gateways/adapter.ex | 71 +++++++++++++ lib/trinity/gateways/console.ex | 104 +++++++++++++++++++ lib/trinity/gateways/format.ex | 132 +++++++++++++++++++++++++ test/trinity/gateways/console_test.exs | 78 +++++++++++++++ test/trinity/gateways/format_test.exs | 84 ++++++++++++++++ 6 files changed, 491 insertions(+) create mode 100644 lib/trinity/gateways.ex create mode 100644 lib/trinity/gateways/adapter.ex create mode 100644 lib/trinity/gateways/console.ex create mode 100644 lib/trinity/gateways/format.ex create mode 100644 test/trinity/gateways/console_test.exs create mode 100644 test/trinity/gateways/format_test.exs diff --git a/lib/trinity/gateways.ex b/lib/trinity/gateways.ex new file mode 100644 index 0000000..1afb8b5 --- /dev/null +++ b/lib/trinity/gateways.ex @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways do + @moduledoc """ + Trinity reached from somewhere other than the desktop (slice 070): a chat channel, a terminal, + later a messaging platform. A gateway is an adapter process plus `Router`, and the rule that + makes the layer safe to extend is that an adapter carries text and nothing else: it never calls + the LLM, never touches a session, and never decides an approval. Whether an effect happens is + the permission gate's answer, as it is for the desktop, and what an approval arriving from a + channel may authorise is capped besides (`Cap`, docs/07 "Gateways"). + + Adapters are configured, not compiled in: `config :trinity, :gateways, adapters: [Module, …]`. + `Console` ships here and is what the tests and `mix trinity.console` talk to. + """ + use Boundary, + deps: [Trinity, Trinity.Sessions], + exports: [Adapter, Console, Format] + + @doc "The adapter modules in force." + @spec adapters() :: [module()] + def adapters, do: Application.get_env(:trinity, :gateways, []) |> Keyword.get(:adapters, []) +end diff --git a/lib/trinity/gateways/adapter.ex b/lib/trinity/gateways/adapter.ex new file mode 100644 index 0000000..dc26402 --- /dev/null +++ b/lib/trinity/gateways/adapter.ex @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Adapter do + @moduledoc """ + What a gateway is (slice 070): a process that carries messages between one outside platform and + `Trinity.Gateways.Router`, and nothing else. An adapter never calls the LLM, never touches + `Trinity.Sessions` and never decides an approval; it receives text from a platform, hands it to + the router, and renders back what the router gives it. That rule is what lets a platform be + added by writing one module and a configuration entry (docs/03, the modularity rule). + + `capabilities/0` is how the router talks to an adapter without knowing the platform: what the + channel can render, how long a message may be, and whether an already-sent message can be + edited (which is what decides whether a stream is delivered as edits or as one final message). + """ + + alias Trinity.Permissions.Approval + + @typedoc "The platform's own identifier for a conversation (a channel, a chat, a DM)." + @type conversation :: String.t() + + @typedoc """ + What a channel can carry. `max_length` is in graphemes, not bytes: a platform counts characters + and so does `Format.chunk/2`. `edits` says an adapter can replace a message it already sent, + which is how a stream reaches a channel without one message per delta. + """ + @type capabilities :: %{ + markdown: boolean(), + images: boolean(), + buttons: boolean(), + edits: boolean(), + max_length: pos_integer() + } + + @typedoc """ + What the router asks an adapter to do. `{:edit, ref, text}` carries the reference the adapter + returned when it sent the message being replaced; an adapter without `edits` never receives one. + """ + @type outbound :: + {:message, String.t()} + | {:edit, reference_id(), String.t()} + | {:typing, boolean()} + + @typedoc "Whatever the adapter uses to name a message it sent, opaque to the router." + @type reference_id :: term() + + @doc "The adapter's child specification; the gateway supervisor starts it with its configuration." + @callback child_spec(keyword()) :: Supervisor.child_spec() + + @doc "What this channel can carry." + @callback capabilities() :: capabilities() + + @doc """ + Delivers one outbound instruction to a conversation. A `{:message, _}` answers with a reference + the router can later edit, when the channel supports editing; anything else answers `:ok`. + """ + @callback deliver(conversation(), outbound()) :: :ok | {:ok, reference_id()} | {:error, term()} + + @doc """ + An assistant message as this platform's text, already split to `max_length`. The default is + `Trinity.Gateways.Format.format/2`, which is what an adapter should delegate to unless its + platform needs its own dialect. + """ + @callback format(String.t(), capabilities()) :: [String.t()] + + @doc """ + An approval request as something the conversation can answer. The default is + `Trinity.Gateways.Format.render_approval/2`, which writes the two commands out, because a + channel without buttons still has to be able to answer. + """ + @callback render_approval(Approval.t(), capabilities()) :: outbound() +end diff --git a/lib/trinity/gateways/console.ex b/lib/trinity/gateways/console.ex new file mode 100644 index 0000000..93be203 --- /dev/null +++ b/lib/trinity/gateways/console.ex @@ -0,0 +1,104 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Console do + @moduledoc """ + The in-process gateway (slice 070): an adapter with no platform behind it. What it delivers is + kept in this process and read back by a test or by `mix trinity.console`, so every acceptance + criterion of this slice runs without an account, a token or a network, and so a developer can + talk to Trinity from a terminal on the same node. + + It is a real adapter and not a mock: the router treats it as it treats any other, and its + capabilities are the ones a plain text channel has (markdown, edits, a four thousand character + limit, no images, no buttons). A platform adapter (071, 072) differs from it in `deliver/2` and + `capabilities/0` and in nothing else. + """ + @behaviour Trinity.Gateways.Adapter + + use GenServer + + alias Trinity.Gateways.Format + + @capabilities %{markdown: true, images: false, buttons: false, edits: true, max_length: 4_000} + + ## The behaviour + + @impl Trinity.Gateways.Adapter + def capabilities, do: @capabilities + + @impl Trinity.Gateways.Adapter + def deliver(conversation, outbound), + do: GenServer.call(__MODULE__, {:deliver, conversation, outbound}) + + @impl Trinity.Gateways.Adapter + defdelegate format(text, capabilities), to: Format + + @impl Trinity.Gateways.Adapter + defdelegate render_approval(approval, capabilities), to: Format + + ## The process + + @doc "Starts the console's mailbox." + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + + @impl GenServer + def init(_opts), do: {:ok, %{conversations: %{}, next_ref: 1}} + + @doc "Everything delivered to a conversation, oldest first." + @spec delivered(Trinity.Gateways.Adapter.conversation()) :: [ + Trinity.Gateways.Adapter.outbound() + ] + def delivered(conversation), do: GenServer.call(__MODULE__, {:delivered, conversation}) + + @doc """ + The text a conversation has been shown: every message and, for an edited message, the text it + last held. This is what a person in the channel would be looking at. + """ + @spec text(Trinity.Gateways.Adapter.conversation()) :: [String.t()] + def text(conversation), do: GenServer.call(__MODULE__, {:text, conversation}) + + @doc "Forgets a conversation's messages (a test's reset)." + @spec clear(Trinity.Gateways.Adapter.conversation()) :: :ok + def clear(conversation), do: GenServer.call(__MODULE__, {:clear, conversation}) + + @impl GenServer + def handle_call({:deliver, conversation, outbound}, _from, state) do + log = Map.get(state.conversations, conversation, []) + state = put_in(state.conversations[conversation], log ++ [outbound]) + + case outbound do + {:message, _} -> + {:reply, {:ok, state.next_ref}, %{state | next_ref: state.next_ref + 1}} + + _ -> + {:reply, :ok, state} + end + end + + def handle_call({:delivered, conversation}, _from, state), + do: {:reply, Map.get(state.conversations, conversation, []), state} + + def handle_call({:text, conversation}, _from, state), + do: {:reply, state.conversations |> Map.get(conversation, []) |> visible(), state} + + def handle_call({:clear, conversation}, _from, state), + do: {:reply, :ok, %{state | conversations: Map.delete(state.conversations, conversation)}} + + # A message is what was sent; an edit replaces the message it names, which is how a stream + # looks to a person watching the channel rather than reading the log. + defp visible(log) do + log + |> Enum.reduce({[], %{}}, fn + {:message, text}, {order, by_ref} -> + ref = map_size(by_ref) + 1 + {order ++ [ref], Map.put(by_ref, ref, text)} + + {:edit, ref, text}, {order, by_ref} -> + {order, Map.put(by_ref, ref, text)} + + {:typing, _}, acc -> + acc + end) + |> then(fn {order, by_ref} -> Enum.map(order, &Map.fetch!(by_ref, &1)) end) + end +end diff --git a/lib/trinity/gateways/format.ex b/lib/trinity/gateways/format.ex new file mode 100644 index 0000000..50b372b --- /dev/null +++ b/lib/trinity/gateways/format.ex @@ -0,0 +1,132 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Format do + @moduledoc """ + Turning what Trinity says into what a channel can carry (slice 070): the default + `format/2` and `render_approval/2` every adapter delegates to unless its platform needs its own + dialect. + + Two jobs. **Chunking**: a platform refuses a message past its limit, so a long answer is split + at the latest paragraph, then line, then word boundary that fits, and only cut mid-word when a + single word is longer than the limit. A fenced code block that has to be split is re-opened on + the next chunk with its own fence, because half a fence renders the rest of the message as code. + **Plain text**: a channel without markdown gets the emphasis and heading markers removed rather + than shown; fences become plain lines. That is a reduction and it is named as one: it is not a + markdown renderer, and nothing here tries to be. + """ + + alias Trinity.Gateways.Adapter + alias Trinity.Permissions.Approval + + @fence "```" + + @doc "An assistant message as the channel's text, split to its length limit." + @spec format(String.t(), Adapter.capabilities()) :: [String.t()] + def format(text, capabilities) when is_binary(text) do + text + |> then(fn t -> if capabilities.markdown, do: t, else: plain(t) end) + |> chunk(capabilities.max_length) + end + + @doc """ + Splits text into pieces of at most `max` graphemes, at the latest boundary that fits. An open + code fence is closed at the end of a chunk and re-opened at the start of the next. + """ + @spec chunk(String.t(), pos_integer()) :: [String.t()] + def chunk(text, max) when is_binary(text) and is_integer(max) and max > 0 do + text + |> do_chunk(max, []) + |> Enum.reverse() + |> reopen_fences() + end + + defp do_chunk(text, max, acc) do + if String.length(text) <= max do + [text | acc] + else + {head, rest} = split_at_boundary(text, max) + do_chunk(String.trim_leading(rest), max, [head | acc]) + end + end + + # The latest paragraph, line or word boundary inside the limit; a hard cut when a single word + # is longer than the limit (a URL, a base64 blob) and there is no boundary to prefer. + defp split_at_boundary(text, max) do + head = String.slice(text, 0, max) + + case boundary(head) do + nil -> {head, String.slice(text, max..-1//1)} + at -> {String.trim_trailing(String.slice(head, 0, at)), String.slice(text, at..-1//1)} + end + end + + defp boundary(head) do + Enum.find_value([~r/\n\n(?!.*\n\n)/s, ~r/\n(?!.*\n)/s, ~r/ (?!.* )/s], fn re -> + case Regex.run(re, head, return: :index) do + [{start, len}] -> start + len + _ -> nil + end + end) + end + + # A chunk holding an odd number of fences left one open: close it, and open the next chunk with + # a fence of its own so the block keeps rendering as code. + defp reopen_fences(chunks) do + {out, _open} = + Enum.map_reduce(chunks, false, fn chunk, open? -> + chunk = if open?, do: @fence <> "\n" <> chunk, else: chunk + now_open? = chunk |> String.split(@fence) |> length() |> rem(2) == 0 + {if(now_open?, do: chunk <> "\n" <> @fence, else: chunk), now_open?} + end) + + out + end + + @doc "Markdown reduced to text for a channel that renders none of it." + @spec plain(String.t()) :: String.t() + def plain(text) when is_binary(text) do + text + |> String.replace(~r/^#{@fence}.*$/m, "") + |> String.replace(~r/^#+\s*/m, "") + |> String.replace(~r/\*\*([^*]+)\*\*/, "\\1") + |> String.replace(~r/(? String.replace(~r/(? String.replace(~r/^[ \t]*[-*][ \t]+/m, "• ") + |> String.replace(~r/\n{3,}/, "\n\n") + |> String.trim() + end + + @doc """ + An approval request as a message the conversation can answer. The commands are written out + whether or not the channel has buttons: a channel without them still has to be able to answer, + and `Trinity.Gateways.Commands` accepts the text form everywhere. + """ + @spec render_approval(Approval.t(), Adapter.capabilities()) :: Adapter.outbound() + def render_approval(%Approval{} = approval, capabilities) do + body = """ + Approval needed: #{approval.tool} (#{approval.risk}) + #{summarise(approval.args)} + Answer with /approve #{short(approval.id)} or /deny #{short(approval.id)} + """ + + {:message, body |> String.trim() |> chunk(capabilities.max_length) |> List.first()} + end + + @doc "The short form of an approval id a person can type back." + @spec short(String.t()) :: String.t() + def short(id) when is_binary(id), do: String.slice(id, 0, 8) + + defp summarise(args) when map_size(args) == 0, do: "(no arguments)" + + defp summarise(args) do + args + |> Enum.sort_by(fn {k, _} -> k end) + |> Enum.map_join(", ", fn {k, v} -> "#{k}=#{truncate(v)}" end) + end + + defp truncate(value) do + value |> to_string() |> String.slice(0, 40) + end +end diff --git a/test/trinity/gateways/console_test.exs b/test/trinity/gateways/console_test.exs new file mode 100644 index 0000000..756cad1 --- /dev/null +++ b/test/trinity/gateways/console_test.exs @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.ConsoleTest do + @moduledoc """ + Slice 070: the in-process adapter. It implements every callback of the behaviour, it records + what it was asked to deliver, and `text/1` answers with what a person in the channel would be + looking at: a message replaced by its edits rather than a log of both. + """ + use ExUnit.Case, async: false + + alias Trinity.Gateways.{Adapter, Console} + + setup do + pid = start_supervised!(Console) + on_exit(fn -> if Process.alive?(pid), do: :ok end) + :ok + end + + test "it implements every callback the behaviour declares" do + callbacks = Adapter.behaviour_info(:callbacks) |> Enum.sort() + exported = Console.__info__(:functions) + + for {name, arity} <- callbacks do + assert {name, arity} in exported or name == :child_spec, + "Console does not implement #{name}/#{arity}" + end + + # child_spec/1 comes from `use GenServer`, which is how the supervisor starts it. + assert function_exported?(Console, :child_spec, 1) + end + + # Written as one equality and not as `assert caps.markdown`: the type checker inlines the + # module attribute and flags an assertion on a literal, rightly, since it proves nothing. What + # is worth holding is the whole declaration, so a change to any field is a change to this line. + test "capabilities declare a plain text channel that can edit, and every field the router reads" do + assert Console.capabilities() == %{ + markdown: true, + images: false, + buttons: false, + edits: true, + max_length: 4_000 + } + end + + test "a delivered message answers with a reference, and an edit replaces what it names" do + assert {:ok, ref} = Console.deliver("c1", {:message, "first"}) + assert :ok = Console.deliver("c1", {:typing, true}) + assert :ok = Console.deliver("c1", {:edit, ref, "first, corrected"}) + assert {:ok, _} = Console.deliver("c1", {:message, "second"}) + + assert Console.text("c1") == ["first, corrected", "second"] + + assert Console.delivered("c1") == [ + {:message, "first"}, + {:typing, true}, + {:edit, ref, "first, corrected"}, + {:message, "second"} + ] + end + + test "conversations are separate, and one can be cleared" do + Console.deliver("a", {:message, "for a"}) + Console.deliver("b", {:message, "for b"}) + assert Console.text("a") == ["for a"] + assert Console.text("b") == ["for b"] + + assert :ok = Console.clear("a") + assert Console.text("a") == [] + assert Console.text("b") == ["for b"] + end + + test "format and render_approval are the shared defaults, not a dialect of its own" do + long = String.duplicate("word ", 2_000) + chunks = Console.format(long, Console.capabilities()) + assert Enum.all?(chunks, &(String.length(&1) <= Console.capabilities().max_length)) + assert length(chunks) > 1 + end +end diff --git a/test/trinity/gateways/format_test.exs b/test/trinity/gateways/format_test.exs new file mode 100644 index 0000000..7e64e7a --- /dev/null +++ b/test/trinity/gateways/format_test.exs @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.FormatTest do + @moduledoc """ + Slice 070: the default formatting every adapter delegates to. Chunking splits at the latest + boundary that fits and never past the limit; a split code fence is closed and re-opened; a + channel without markdown is given the markers removed rather than shown; an approval renders + with the two commands a person can type. + """ + use ExUnit.Case, async: true + + alias Trinity.Gateways.Format + alias Trinity.Permissions.Approval + + @caps %{markdown: true, images: false, buttons: false, edits: true, max_length: 40} + + test "a message inside the limit is one chunk, unchanged" do + assert Format.chunk("short enough", 40) == ["short enough"] + assert Format.format("short enough", @caps) == ["short enough"] + end + + test "chunks never exceed the limit and prefer the latest paragraph, line, then word boundary" do + paragraphs = "#{String.duplicate("a", 20)}\n\n#{String.duplicate("b", 20)}" + assert [first, second] = Format.chunk(paragraphs, 30) + assert first == String.duplicate("a", 20) + assert second == String.duplicate("b", 20) + + lines = "#{String.duplicate("a", 20)}\n#{String.duplicate("b", 20)}" + assert [^first, ^second] = Format.chunk(lines, 30) + + words = String.duplicate("word ", 20) + chunks = Format.chunk(words, 30) + assert Enum.all?(chunks, &(String.length(&1) <= 30)) + + assert chunks |> Enum.join(" ") |> String.replace(~r/\s+/, " ") |> String.trim() == + String.trim(words) + end + + test "a single word longer than the limit is cut, because there is no boundary to prefer" do + long = String.duplicate("x", 95) + chunks = Format.chunk(long, 40) + assert Enum.map(chunks, &String.length/1) == [40, 40, 15] + assert Enum.join(chunks) == long + end + + test "a code fence split across chunks is closed and re-opened, so the rest is not swallowed" do + code = "```\n" <> String.duplicate("line of code\n", 8) <> "```" + chunks = Format.chunk(code, 60) + assert length(chunks) > 1 + assert Enum.all?(chunks, &(String.length(&1) <= 60 + 8)) + + # Every chunk holds an even number of fences: none of them leaves a block open. + for chunk <- chunks do + assert chunk |> String.split("```") |> length() |> rem(2) == 1, + "unbalanced fence in #{chunk}" + end + end + + test "a channel without markdown is given the markers removed, not shown" do + caps = %{@caps | markdown: false, max_length: 4_000} + text = "# Heading\n\nSome **bold** and *italic* and `code`.\n\n- one\n- two" + assert [plain] = Format.format(text, caps) + assert plain == "Heading\n\nSome bold and italic and code.\n\n• one\n• two" + refute plain =~ "**" + refute plain =~ "#" + end + + test "an approval renders with its tool, its tier and the two commands that answer it" do + approval = %Approval{ + id: "0198f0c0-1111-2222-3333-444455556666", + tool: "write_note", + risk: "write", + args: %{"path" => "notes/today.md", "body" => String.duplicate("x", 80)} + } + + assert {:message, text} = Format.render_approval(approval, %{@caps | max_length: 4_000}) + assert text =~ "Approval needed: write_note (write)" + assert text =~ "path=notes/today.md" + assert text =~ "/approve 0198f0c0" + assert text =~ "/deny 0198f0c0" + # The arguments are summarised, never pasted whole. + refute text =~ String.duplicate("x", 45) + end +end From 9baa0d1c066d660c9b66d47f782d894c7acd3a7e Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 20:12:06 -0400 Subject: [PATCH 03/10] feat(s070): gateway identities and pairing: an unknown sender gets a code and nothing else, the code pairs once, a revoked row stays visible Signed-off-by: Ayla Croft --- lib/trinity/gateways/identities.ex | 188 ++++++++++++++++++ lib/trinity/gateways/identity.ex | 67 +++++++ ...260922200000_create_gateway_identities.exs | 30 +++ test/trinity/gateways/identities_test.exs | 95 +++++++++ 4 files changed, 380 insertions(+) create mode 100644 lib/trinity/gateways/identities.ex create mode 100644 lib/trinity/gateways/identity.ex create mode 100644 priv/repo/migrations/20260922200000_create_gateway_identities.exs create mode 100644 test/trinity/gateways/identities_test.exs diff --git a/lib/trinity/gateways/identities.ex b/lib/trinity/gateways/identities.ex new file mode 100644 index 0000000..bf340ae --- /dev/null +++ b/lib/trinity/gateways/identities.ex @@ -0,0 +1,188 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Identities do + @moduledoc """ + Who may talk to Trinity from outside (slice 070): the `gateway_identities` rows and the pairing + that creates them. + + The rule this module exists to hold, from docs/07: **an unknown sender gets a pairing prompt and + nothing else.** No session is created, no model is called, nothing is remembered but the code + that was shown. Pairing is proved on the desktop, where the owner already is: the code appears + on `/gateways`, the sender types it back into the channel, and only then does the row become + `paired`. A configured allowlist (`config :trinity, :gateways, allowlist: [{adapter, id}, …]`) + pairs an id at first sight, for a deployment that already knows who it is talking to. + + A code is six characters from an alphabet without the letters and digits that are read for one + another, lives ten minutes, and is compared in constant time. + """ + + import Ecto.Query, only: [from: 2] + + alias Trinity.Gateways.Identity + alias Trinity.Repo + + @alphabet ~c"ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + @code_length 6 + @code_ttl_s 600 + + @doc "Every identity, newest first." + @spec list() :: [Identity.t()] + def list, do: Repo.all(from i in Identity, order_by: [desc: i.inserted_at]) + + @doc "The identity for an adapter and an external id, or nil." + @spec get(String.t(), String.t()) :: Identity.t() | nil + def get(adapter, external_user_id), + do: Repo.get_by(Identity, adapter: adapter, external_user_id: external_user_id) + + @doc """ + The identity an inbound message belongs to, creating a `pending` row with a fresh code when the + sender is unknown, or a `paired` one when the allowlist already names them. The second element + says what the caller must do: `:paired` to carry on, `:pending` to show the code and stop. + """ + @spec admit(String.t(), String.t(), keyword()) :: + {:ok, Identity.t(), :paired | :pending | :revoked} | {:error, Ecto.Changeset.t()} + def admit(adapter, external_user_id, opts \\ []) do + case get(adapter, external_user_id) do + nil -> first_sight(adapter, external_user_id, opts) + %Identity{state: "paired"} = identity -> {:ok, touch(identity, opts), :paired} + %Identity{state: "revoked"} = identity -> {:ok, identity, :revoked} + %Identity{} = identity -> {:ok, refresh_code(identity, opts), :pending} + end + end + + @doc """ + Answers a pairing attempt: the code the sender typed, against the code this identity was shown. + Wrong or expired leaves the row pending, and says so rather than saying which. + """ + @spec pair(String.t(), String.t(), String.t()) :: + {:ok, Identity.t()} | {:error, :no_code | :wrong_code | :expired | :revoked} + def pair(adapter, external_user_id, given) when is_binary(given) do + with %Identity{} = identity <- get(adapter, external_user_id), + :ok <- pairable(identity), + :ok <- code_matches(identity, given), + :ok <- code_live(identity) do + {:ok, + update!(identity, %{state: "paired", paired_at: now(), code: nil, code_expires_at: nil})} + else + nil -> {:error, :no_code} + {:error, _} = error -> error + end + end + + @doc "Turns an identity away; the row stays, so the refusal is visible." + @spec revoke(Identity.t()) :: {:ok, Identity.t()} | {:error, Ecto.Changeset.t()} + def revoke(%Identity{} = identity), + do: identity |> Identity.changeset(%{state: "revoked", revoked_at: now()}) |> Repo.update() + + @doc "Pairs an identity from the desktop, without a code (the owner is already proved here)." + @spec allow(Identity.t()) :: {:ok, Identity.t()} | {:error, Ecto.Changeset.t()} + def allow(%Identity{} = identity) do + identity + |> Identity.changeset(%{state: "paired", paired_at: now(), code: nil, code_expires_at: nil}) + |> Repo.update() + end + + @doc "The identities waiting with a live code, for the page that shows them." + @spec pending() :: [Identity.t()] + def pending do + now = now() + + Repo.all( + from i in Identity, + where: i.state == "pending" and i.code_expires_at > ^now, + order_by: [desc: i.inserted_at] + ) + end + + @doc "A fresh pairing code." + @spec generate_code() :: String.t() + def generate_code do + for _ <- 1..@code_length, into: "", do: <> + end + + @doc "How long a code lives, in seconds." + @spec code_ttl_s() :: pos_integer() + def code_ttl_s, do: @code_ttl_s + + defp first_sight(adapter, external_user_id, opts) do + attrs = %{ + adapter: adapter, + external_user_id: external_user_id, + display_name: Keyword.get(opts, :display_name), + last_conversation: Keyword.get(opts, :conversation) + } + + if allowlisted?(adapter, external_user_id) do + insert(Map.merge(attrs, %{state: "paired", paired_at: now()}), :paired) + else + insert( + Map.merge(attrs, %{state: "pending", code: generate_code(), code_expires_at: expiry()}), + :pending + ) + end + end + + defp insert(attrs, state) do + case %Identity{} |> Identity.changeset(attrs) |> Repo.insert() do + {:ok, identity} -> {:ok, identity, state} + {:error, _} = error -> error + end + end + + # A pending identity that comes back after its code expired is shown a new one: the alternative + # is a sender who can never pair because the first code they were given has gone stale. + defp refresh_code(identity, opts) do + if live_code?(identity) do + touch(identity, opts) + else + update!(identity, %{ + code: generate_code(), + code_expires_at: expiry(), + last_conversation: Keyword.get(opts, :conversation, identity.last_conversation) + }) + end + end + + defp touch(identity, opts) do + case Keyword.get(opts, :conversation) do + nil -> identity + same when same == identity.last_conversation -> identity + conversation -> update!(identity, %{last_conversation: conversation}) + end + end + + defp update!(identity, attrs), do: identity |> Identity.changeset(attrs) |> Repo.update!() + + defp pairable(%Identity{state: "revoked"}), do: {:error, :revoked} + defp pairable(%Identity{state: "paired"} = _identity), do: :ok + defp pairable(%Identity{code: nil}), do: {:error, :no_code} + defp pairable(%Identity{}), do: :ok + + defp code_matches(%Identity{code: nil}, _given), do: {:error, :no_code} + + defp code_matches(%Identity{code: code}, given) do + given = given |> String.trim() |> String.upcase() + + if byte_size(given) == byte_size(code) and :crypto.hash_equals(given, code), + do: :ok, + else: {:error, :wrong_code} + end + + defp code_live(identity), do: if(live_code?(identity), do: :ok, else: {:error, :expired}) + + defp live_code?(%Identity{code: code, code_expires_at: at}), + do: is_binary(code) and not is_nil(at) and DateTime.compare(at, now()) == :gt + + defp allowlisted?(adapter, external_user_id) do + :trinity + |> Application.get_env(:gateways, []) + |> Keyword.get(:allowlist, []) + |> Enum.any?(fn + {a, id} -> to_string(a) == adapter and to_string(id) == external_user_id + _ -> false + end) + end + + defp expiry, do: DateTime.add(now(), @code_ttl_s, :second) + defp now, do: DateTime.utc_now() +end diff --git a/lib/trinity/gateways/identity.ex b/lib/trinity/gateways/identity.ex new file mode 100644 index 0000000..86ef7c5 --- /dev/null +++ b/lib/trinity/gateways/identity.ex @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Identity do + @moduledoc """ + One row of `gateway_identities` (slice 070, docs/05, docs/07 "Gateways"): a person on one + outside platform and what Trinity will do for them. The key is the pair `(adapter, + external_user_id)`, because an id means nothing outside the platform that issued it. + + `state` is `pending` (a pairing code has been shown and not yet entered), `paired` (the owner + typed the code into the desktop's channel, which is the proof) or `revoked`. A revoked row is + kept rather than deleted: an id that was turned away should be visible on the page, not absent + from it, and pairing again is then a deliberate act with a record. + """ + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, Trinity.UUID, autogenerate: true} + @foreign_key_type Trinity.UUID + @timestamps_opts [type: :utc_datetime_usec] + + @type t :: %__MODULE__{} + + @states ~w(pending paired revoked) + + schema "gateway_identities" do + field :adapter, :string + field :external_user_id, :string + field :display_name, :string + field :state, :string, default: "pending" + field :code, :string + field :code_expires_at, :utc_datetime_usec + field :paired_at, :utc_datetime_usec + field :revoked_at, :utc_datetime_usec + field :last_conversation, :string + timestamps() + end + + @doc "The states a row may be in." + @spec states() :: [String.t()] + def states, do: @states + + @doc "A changeset for an identity." + @spec changeset(t(), map()) :: Ecto.Changeset.t() + def changeset(identity, attrs) do + identity + |> cast(attrs, [ + :adapter, + :external_user_id, + :display_name, + :state, + :code, + :code_expires_at, + :paired_at, + :revoked_at, + :last_conversation + ]) + |> validate_required([:adapter, :external_user_id, :state]) + |> validate_inclusion(:state, @states) + |> unique_constraint([:adapter, :external_user_id]) + end + + @doc "True when this identity may talk to Trinity right now." + @spec paired?(t()) :: boolean() + def paired?(%__MODULE__{state: "paired"}), do: true + def paired?(%__MODULE__{}), do: false +end diff --git a/priv/repo/migrations/20260922200000_create_gateway_identities.exs b/priv/repo/migrations/20260922200000_create_gateway_identities.exs new file mode 100644 index 0000000..802b3a0 --- /dev/null +++ b/priv/repo/migrations/20260922200000_create_gateway_identities.exs @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Repo.Migrations.CreateGatewayIdentities do + use Ecto.Migration + + # Slice 070 (docs/05, docs/07 "Gateways"): who outside this machine may talk to Trinity. One + # row per (adapter, external user), unique on the pair: an id is only ever known within the + # platform that issued it, so the adapter is half the key. `state` is "pending" (a code was + # shown and not yet entered), "paired" (the owner proved it) or "revoked" (kept rather than + # deleted, so a revoked id cannot silently re-pair itself without appearing here). + def change do + create table(:gateway_identities, primary_key: false) do + add :id, :binary_id, primary_key: true + add :adapter, :string, null: false + add :external_user_id, :string, null: false + add :display_name, :string + add :state, :string, null: false, default: "pending" + add :code, :string + add :code_expires_at, :utc_datetime_usec + add :paired_at, :utc_datetime_usec + add :revoked_at, :utc_datetime_usec + # The conversation the pairing was asked from, so the answer goes back where it came from. + add :last_conversation, :string + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:gateway_identities, [:adapter, :external_user_id]) + create index(:gateway_identities, [:state]) + end +end diff --git a/test/trinity/gateways/identities_test.exs b/test/trinity/gateways/identities_test.exs new file mode 100644 index 0000000..526cb0b --- /dev/null +++ b/test/trinity/gateways/identities_test.exs @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.IdentitiesTest do + @moduledoc """ + Slice 070, AC2's automatic half and the rule docs/07 states: an unknown sender is admitted as + `pending` with a code and nothing else; the code pairs them once, is refused when wrong, + expired or already used; a revoked identity cannot pair; an allowlisted id is paired at first + sight; and a pending identity whose code has expired is given a new one rather than being + locked out. + """ + use Trinity.DataCase, async: false + + alias Trinity.Gateways.{Identities, Identity} + + @adapter "console" + @user "u-1" + + test "an unknown sender is pending with a live code, and nothing else is created" do + assert {:ok, %Identity{} = identity, :pending} = + Identities.admit(@adapter, @user, conversation: "c1", display_name: "Someone") + + assert identity.state == "pending" + assert String.length(identity.code) == 6 + assert identity.code =~ ~r/^[A-Z2-9]+$/ + assert DateTime.compare(identity.code_expires_at, DateTime.utc_now()) == :gt + assert identity.paired_at == nil + assert identity.last_conversation == "c1" + assert Identities.pending() |> Enum.map(& &1.id) == [identity.id] + end + + test "the code pairs the sender once; a wrong code, an expired one and a second use are refused" do + {:ok, identity, :pending} = Identities.admit(@adapter, @user) + + assert {:error, :wrong_code} = Identities.pair(@adapter, @user, "AAAAAA") + assert {:ok, paired} = Identities.pair(@adapter, @user, String.downcase(identity.code)) + assert paired.state == "paired" and paired.paired_at != nil + assert paired.code == nil, "the code is spent, not kept" + + # A second attempt with the same code has nothing to match. + assert {:error, :no_code} = Identities.pair(@adapter, @user, identity.code) + + # And the next message is simply admitted. + assert {:ok, _, :paired} = Identities.admit(@adapter, @user) + end + + test "an expired code is refused, and the next message is given a fresh one" do + {:ok, identity, :pending} = Identities.admit(@adapter, @user) + stale = DateTime.add(DateTime.utc_now(), -1, :second) + {:ok, _} = identity |> Identity.changeset(%{code_expires_at: stale}) |> Trinity.Repo.update() + + assert {:error, :expired} = Identities.pair(@adapter, @user, identity.code) + assert Identities.pending() == [] + + assert {:ok, refreshed, :pending} = Identities.admit(@adapter, @user) + assert refreshed.code != identity.code + assert {:ok, _} = Identities.pair(@adapter, @user, refreshed.code) + end + + test "a revoked identity cannot pair and is not admitted" do + {:ok, identity, :pending} = Identities.admit(@adapter, @user) + assert {:ok, revoked} = Identities.revoke(identity) + assert revoked.state == "revoked" and revoked.revoked_at != nil + + assert {:ok, _, :revoked} = Identities.admit(@adapter, @user) + assert {:error, :revoked} = Identities.pair(@adapter, @user, identity.code) + # The row stays: a refusal is visible on the page rather than absent from it. + assert Enum.map(Identities.list(), & &1.id) == [identity.id] + end + + test "an allowlisted id is paired at first sight, with no code shown" do + Application.put_env(:trinity, :gateways, allowlist: [{"console", "trusted"}]) + on_exit(fn -> Application.delete_env(:trinity, :gateways) end) + + assert {:ok, identity, :paired} = Identities.admit(@adapter, "trusted") + assert identity.state == "paired" and identity.code == nil + assert Identities.pending() == [] + + # The allowlist names one pair, not a platform: another id on the same adapter still pairs. + assert {:ok, _, :pending} = Identities.admit(@adapter, "someone-else") + end + + test "the same external id on two adapters is two identities" do + assert {:ok, one, :pending} = Identities.admit("console", "same-id") + assert {:ok, two, :pending} = Identities.admit("other", "same-id") + assert one.id != two.id + assert {:ok, _} = Identities.pair("console", "same-id", one.code) + assert {:ok, _, :pending} = Identities.admit("other", "same-id") + end + + test "the desktop can pair and revoke without a code" do + {:ok, identity, :pending} = Identities.admit(@adapter, @user) + assert {:ok, allowed} = Identities.allow(identity) + assert allowed.state == "paired" and allowed.code == nil + end +end From 345b0e7d8bd35ebd94bed0934b105e309b24fa0a Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 20:16:48 -0400 Subject: [PATCH 04/10] feat(s070): the router: admission before anything else, a token bucket, session binding with the adapter as origin, the stream coalesced into edits, and the slash commands Signed-off-by: Ayla Croft --- lib/trinity/gateways/adapter.ex | 10 + lib/trinity/gateways/commands.ex | 119 ++++++++ lib/trinity/gateways/router.ex | 395 ++++++++++++++++++++++++++ test/trinity/gateways/router_test.exs | 192 +++++++++++++ 4 files changed, 716 insertions(+) create mode 100644 lib/trinity/gateways/commands.ex create mode 100644 lib/trinity/gateways/router.ex create mode 100644 test/trinity/gateways/router_test.exs diff --git a/lib/trinity/gateways/adapter.ex b/lib/trinity/gateways/adapter.ex index dc26402..c12295d 100644 --- a/lib/trinity/gateways/adapter.ex +++ b/lib/trinity/gateways/adapter.ex @@ -43,6 +43,16 @@ defmodule Trinity.Gateways.Adapter do @typedoc "Whatever the adapter uses to name a message it sent, opaque to the router." @type reference_id :: term() + @doc """ + The adapter's name as rows and receipts carry it: the last segment of its module, underscored, + so `Trinity.Gateways.Console` is `"console"`. It is derived and not declared, because a name a + module can contradict is a second source of truth (CLAUDE.md section 8: a name is a claim). + """ + @spec name(module()) :: String.t() + def name(adapter) when is_atom(adapter) do + adapter |> Module.split() |> List.last() |> Macro.underscore() + end + @doc "The adapter's child specification; the gateway supervisor starts it with its configuration." @callback child_spec(keyword()) :: Supervisor.child_spec() diff --git a/lib/trinity/gateways/commands.ex b/lib/trinity/gateways/commands.ex new file mode 100644 index 0000000..c1ae021 --- /dev/null +++ b/lib/trinity/gateways/commands.ex @@ -0,0 +1,119 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Commands do + @moduledoc """ + What a channel can ask for without talking to the model (slice 070): a registry of slash + commands, so a platform adapter adds none of its own and a new command is a clause here rather + than a change to the router. + + Every command answers with text the router sends back. A command never decides an approval by + itself: `/approve` and `/deny` go through + `Trinity.Permissions`, after `Trinity.Gateways.Cap` has said whether a channel of this kind may + answer a request of that tier at all (docs/07, the channel trust cap). + """ + + alias Trinity.Gateways.Router + alias Trinity.Sessions + + @doc "The commands, with one line of help each." + @spec help() :: [{String.t(), String.t()}] + def help do + [ + {"/new", "start a fresh session for this conversation"}, + {"/sessions", "list the most recent sessions"}, + {"/attach ", "watch an existing session from here"}, + {"/help", "this list"} + ] + end + + @doc "Runs a command for an inbound message: the text for the channel, and the router's state." + @spec handle(map(), map()) :: {String.t(), map()} + def handle(%{text: text} = message, state) do + {command, argument} = split(text) + run(command, argument, message, state) + end + + defp split(text) do + case text |> String.trim() |> String.split(" ", parts: 2) do + [command] -> {command, ""} + [command, rest] -> {command, String.trim(rest)} + end + end + + defp run("/help", _argument, _message, state) do + body = Enum.map_join(help(), "\n", fn {name, line} -> "#{name} — #{line}" end) + {"What I answer here:\n" <> body, state} + end + + defp run("/new", _argument, message, state) do + {"Started a new session.", forget(state, message)} + end + + defp run("/sessions", _argument, _message, state) do + case Sessions.list_sessions(limit: 5) do + [] -> + {"No sessions yet.", state} + + sessions -> + body = + Enum.map_join(sessions, "\n", fn session -> + "#{String.slice(session.id, 0, 8)} — #{session.title || "(untitled)"} (#{session.origin})" + end) + + {"Recent sessions:\n" <> body, state} + end + end + + defp run("/attach", "", _message, state), do: {"Say /attach .", state} + + defp run("/attach", argument, message, state) do + case resolve_session(argument) do + nil -> + {"No session starts with #{argument}.", state} + + session -> + state = Router.bind_state(state, message.adapter, message.conversation, session.id) + + {"Attached to #{String.slice(session.id, 0, 8)}. Both surfaces see this session now.", + state} + end + end + + defp run(unknown, _argument, _message, state), + do: {"I do not know #{unknown}. Say /help for the list.", state} + + # `/new` forgets the binding; the next message creates a session as a first message does. + defp forget(state, message) do + conv_key = {Trinity.Gateways.Adapter.name(message.adapter), message.conversation} + + case get_in(state.conversations, [conv_key, :session_id]) do + nil -> + state + + session_id -> + state + |> update_in([:conversations], &Map.delete(&1, conv_key)) + |> update_in([:sessions], &Map.delete(&1, session_id)) + end + end + + # A person types the short form they were shown, so a prefix resolves when it is unambiguous. + # The full id is only looked up when it is one: `get_session/1` casts, and a prefix reaches the + # repo as a malformed UUID and raises, which is a message from a channel crashing the router. + defp resolve_session(argument) do + if uuid?(argument), + do: Sessions.get_session(argument) || by_prefix(argument), + else: by_prefix(argument) + end + + defp uuid?(argument), + do: + Regex.match?(~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, argument) + + defp by_prefix(prefix) do + case Enum.filter(Sessions.list_sessions(limit: 50), &String.starts_with?(&1.id, prefix)) do + [session] -> session + _ -> nil + end + end +end diff --git a/lib/trinity/gateways/router.ex b/lib/trinity/gateways/router.ex new file mode 100644 index 0000000..97c3813 --- /dev/null +++ b/lib/trinity/gateways/router.ex @@ -0,0 +1,395 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Router do + @moduledoc """ + The one road between a channel and Trinity (slice 070). An adapter hands it an inbound message + and it decides, in this order and no other: is this identity admitted (`Identities`), is it + inside its rate limit, is the text a command (`Commands`), and otherwise whose session does it + belong to. Then it sends the text to that session and streams the answer back to the channel. + + Why the order matters. An unknown sender reaches the pairing check and stops there: no session + is created and no model is called for someone who has not been let in, which is docs/07's rule + rather than an implementation detail. The rate limit sits above the session so a flood costs a + row lookup and nothing else. + + **Streaming.** The router subscribes to `session:` once per conversation and coalesces + `{:assistant_delta, _}` into one message it edits while the turn runs, when the adapter can + edit; a channel that cannot edit is sent the finished message once, because a message per delta + is unreadable and, on a real platform, rate-limited into oblivion. The turn's final + `{:assistant_message, _}` is always delivered, so a channel sees the whole answer whatever its + capabilities. + """ + use GenServer + + alias Trinity.Gateways.{Adapter, Commands, Format, Identities} + alias Trinity.Sessions + + require Logger + + @flush_ms 700 + @default_rate [capacity: 20, per_minute: 20] + + @typedoc "What an adapter hands in: who said what, where." + @type inbound :: %{ + adapter: module(), + conversation: Adapter.conversation(), + external_user_id: String.t(), + text: String.t(), + display_name: String.t() | nil + } + + ## The API an adapter uses + + @doc "Starts the router." + @spec start_link(keyword()) :: GenServer.on_start() + def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) + + @doc """ + One inbound message. Answers once the message has been placed (the reply arrives on the channel + as the turn streams), or with why it was refused: `{:error, :pending}` when a pairing code was + shown instead, `{:error, :revoked}`, `{:error, :rate_limited}`. + """ + @spec inbound(module(), Adapter.conversation(), String.t(), String.t(), keyword()) :: + {:ok, :placed | :command | :paired} | {:error, term()} + def inbound(adapter, conversation, external_user_id, text, opts \\ []) do + message = %{ + adapter: adapter, + conversation: conversation, + external_user_id: external_user_id, + text: text, + display_name: Keyword.get(opts, :display_name) + } + + GenServer.call(__MODULE__, {:inbound, message}, 30_000) + end + + @doc "The session a conversation is bound to, or nil." + @spec session_of(module(), Adapter.conversation()) :: String.t() | nil + def session_of(adapter, conversation), + do: GenServer.call(__MODULE__, {:session_of, adapter, conversation}) + + @doc """ + Binds a conversation to a session that already exists (`/attach`), so the desktop and the + channel watch one stream. The conversation's own session, if it had one, is left alone. + """ + @spec attach(module(), Adapter.conversation(), String.t()) :: :ok | {:error, :no_session} + def attach(adapter, conversation, session_id), + do: GenServer.call(__MODULE__, {:attach, adapter, conversation, session_id}) + + @doc "Sends an outbound instruction to a conversation through its adapter (used by delivery)." + @spec deliver(module(), Adapter.conversation(), Adapter.outbound()) :: :ok + def deliver(adapter, conversation, outbound) do + _ = adapter.deliver(conversation, outbound) + :ok + end + + ## The process + + @impl GenServer + def init(_opts) do + {:ok, %{conversations: %{}, sessions: %{}, buckets: %{}}} + end + + @impl GenServer + def handle_call({:inbound, message}, _from, state) do + case admit(message) do + {:ok, :paired} -> route(message, state) + {:refused, reply, result} -> {:reply, result, say(message, reply, state)} + end + end + + def handle_call({:session_of, adapter, conversation}, _from, state), + do: {:reply, get_in(state.conversations, [key(adapter, conversation), :session_id]), state} + + def handle_call({:attach, adapter, conversation, session_id}, _from, state) do + case Sessions.get_session(session_id) do + nil -> {:reply, {:error, :no_session}, state} + _row -> {:reply, :ok, bind_state(state, adapter, conversation, session_id)} + end + end + + # The stream: everything on `session:` for a bound conversation. + @impl GenServer + def handle_info({:session, session_id, event}, state) do + case Map.get(state.sessions, session_id) do + nil -> {:noreply, state} + conv_key -> {:noreply, stream(event, conv_key, state)} + end + end + + def handle_info({:flush, conv_key}, state), do: {:noreply, flush(conv_key, state)} + def handle_info(_other, state), do: {:noreply, state} + + ## Admission + + defp admit(message) do + adapter_name = Adapter.name(message.adapter) + + case Identities.admit(adapter_name, message.external_user_id, + conversation: message.conversation, + display_name: message.display_name + ) do + {:ok, _identity, :paired} -> + {:ok, :paired} + + {:ok, identity, :pending} -> + pairing_reply(adapter_name, message, identity) + + {:ok, _identity, :revoked} -> + {:refused, "This identity is not allowed to use Trinity.", {:error, :revoked}} + + {:error, _changeset} -> + {:refused, "Something went wrong admitting you.", {:error, :not_admitted}} + end + end + + # A pending sender's message is never a message: it is either the code, or it is answered with + # the prompt again. Nothing else of it is read. + defp pairing_reply(adapter_name, message, identity) do + case Identities.pair(adapter_name, message.external_user_id, message.text) do + {:ok, _paired} -> + {:refused, "Paired. Say anything and Trinity will answer.", {:ok, :paired}} + + {:error, _reason} -> + {:refused, prompt_for(identity), {:error, :pending}} + end + end + + defp prompt_for(identity) do + """ + Trinity does not know you yet. Open the desktop app, go to Settings → Gateways, and you will + see the code #{identity.code}. Send that code here to pair. It lasts #{div(Identities.code_ttl_s(), 60)} minutes. + """ + |> String.trim() + end + + ## Routing an admitted message + + defp route(message, state) do + case take_token(state, message) do + {:error, state} -> + {:reply, {:error, :rate_limited}, + say(message, "Too many messages. Wait a moment.", state)} + + {:ok, state} -> + dispatch(message, state) + end + end + + # A command runs for text an outside channel chose, and this process holds every conversation's + # binding: a command that raises must cost its own message and nothing else. The reason is + # logged and the channel is told, rather than the router dying and every conversation with it. + defp dispatch(%{text: "/" <> _} = message, state) do + {reply, state} = Commands.handle(message, state) + {:reply, {:ok, :command}, say(message, reply, state)} + rescue + error -> + Logger.error("gateway command failed: #{Exception.message(error)}") + {:reply, {:error, :command_failed}, say(message, "That command did not work.", state)} + end + + defp dispatch(message, state) do + {session_id, state} = session_for(message, state) + + case Sessions.send_user_message(session_id, message.text) do + {:ok, _} -> + {:reply, {:ok, :placed}, state} + + {:error, reason} -> + Logger.warning("gateway: message refused: #{inspect(reason)}") + + {:reply, {:error, reason}, + say(message, "Trinity is busy with the previous message.", state)} + end + end + + ## Sessions and binding + + defp session_for(message, state) do + conv_key = key(message.adapter, message.conversation) + + case get_in(state.conversations, [conv_key, :session_id]) do + nil -> + {:ok, row} = create_session(message) + {row.id, bind_state(state, message.adapter, message.conversation, row.id)} + + session_id -> + {session_id, state} + end + end + + defp create_session(message) do + Sessions.create_session(%{ + persona_id: Sessions.default_persona().id, + origin: Adapter.name(message.adapter), + title: "#{Adapter.name(message.adapter)}:#{message.conversation}", + origin_ref: %{ + "adapter" => Adapter.name(message.adapter), + "conversation" => message.conversation, + "external_user_id" => message.external_user_id + } + }) + end + + @doc """ + Binds a conversation to a session, as a state transition. Public because `Commands` runs inside + this process: a command that called `attach/3` would be this process calling itself, which is a + deadlock the suite found. Callers outside the process use `attach/3`. + """ + @spec bind_state(map(), module(), Adapter.conversation(), String.t()) :: map() + def bind_state(state, adapter, conversation, session_id) do + conv_key = key(adapter, conversation) + :ok = Sessions.subscribe(session_id) + + conversation_state = %{ + adapter: adapter, + conversation: conversation, + session_id: session_id, + buffer: "", + ref: nil, + timer: nil + } + + state + |> put_in([:conversations, conv_key], conversation_state) + |> put_in([:sessions, session_id], conv_key) + end + + ## The stream to the channel + + defp stream({:assistant_delta, delta}, conv_key, state) do + conv = Map.fetch!(state.conversations, conv_key) + + if conv.adapter.capabilities().edits do + state + |> put_in([:conversations, conv_key, :buffer], conv.buffer <> delta) + |> schedule_flush(conv_key) + else + state + end + end + + defp stream({:assistant_message, message}, conv_key, state) do + conv = Map.fetch!(state.conversations, conv_key) + state = cancel_timer(state, conv_key) + text = message.content || conv.buffer + + for chunk <- + conv.adapter.format(text, conv.adapter.capabilities()) |> then(&edit_or_send(conv, &1)) do + chunk + end + + state + |> put_in([:conversations, conv_key, :buffer], "") + |> put_in([:conversations, conv_key, :ref], nil) + end + + defp stream({:error, reason}, conv_key, state) do + conv = Map.fetch!(state.conversations, conv_key) + + _ = + conv.adapter.deliver( + conv.conversation, + {:message, "Trinity hit an error: #{inspect(reason)}"} + ) + + state + end + + defp stream(_event, _conv_key, state), do: state + + # The finished answer: the message being edited becomes the first chunk, the rest are new + # messages, so a long answer arrives whole on a channel with a length limit. + defp edit_or_send(conv, [first | rest]) do + case conv.ref do + nil -> _ = conv.adapter.deliver(conv.conversation, {:message, first}) + ref -> _ = conv.adapter.deliver(conv.conversation, {:edit, ref, first}) + end + + for chunk <- rest, do: conv.adapter.deliver(conv.conversation, {:message, chunk}) + end + + defp edit_or_send(_conv, []), do: [] + + defp schedule_flush(state, conv_key) do + case get_in(state.conversations, [conv_key, :timer]) do + nil -> + timer = Process.send_after(self(), {:flush, conv_key}, @flush_ms) + put_in(state.conversations[conv_key][:timer], timer) + + _running -> + state + end + end + + defp flush(conv_key, state) do + case Map.get(state.conversations, conv_key) do + nil -> state + %{buffer: ""} -> put_in(state.conversations[conv_key][:timer], nil) + conv -> flush_buffer(conv, conv_key, state) + end + end + + defp flush_buffer(conv, conv_key, state) do + [partial | _] = conv.adapter.format(conv.buffer, conv.adapter.capabilities()) + + ref = + case conv.ref do + nil -> + case conv.adapter.deliver(conv.conversation, {:message, partial}) do + {:ok, ref} -> ref + _ -> nil + end + + ref -> + _ = conv.adapter.deliver(conv.conversation, {:edit, ref, partial}) + ref + end + + state + |> put_in([:conversations, conv_key, :ref], ref) + |> put_in([:conversations, conv_key, :timer], nil) + end + + defp cancel_timer(state, conv_key) do + case get_in(state.conversations, [conv_key, :timer]) do + nil -> state + timer -> Process.cancel_timer(timer) && put_in(state.conversations[conv_key][:timer], nil) + end + end + + ## Rate limiting: a token bucket per identity, in this process's own state + + defp take_token(state, message) do + config = Keyword.merge(@default_rate, rate_config()) + capacity = Keyword.fetch!(config, :capacity) + per_minute = Keyword.fetch!(config, :per_minute) + bucket_key = {Adapter.name(message.adapter), message.external_user_id} + now = System.monotonic_time(:millisecond) + {tokens, last} = Map.get(state.buckets, bucket_key, {capacity, now}) + refilled = min(capacity, tokens + (now - last) * per_minute / 60_000) + + if refilled >= 1 do + {:ok, put_in(state.buckets[bucket_key], {refilled - 1, now})} + else + {:error, put_in(state.buckets[bucket_key], {refilled, now})} + end + end + + defp rate_config, + do: :trinity |> Application.get_env(:gateways, []) |> Keyword.get(:rate_limit, []) + + ## Saying something back without a session + + defp say(message, text, state) do + caps = message.adapter.capabilities() + + for chunk <- Format.chunk(text, caps.max_length) do + message.adapter.deliver(message.conversation, {:message, chunk}) + end + + state + end + + defp key(adapter, conversation), do: {Adapter.name(adapter), conversation} +end diff --git a/test/trinity/gateways/router_test.exs b/test/trinity/gateways/router_test.exs new file mode 100644 index 0000000..50019c4 --- /dev/null +++ b/test/trinity/gateways/router_test.exs @@ -0,0 +1,192 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.RouterTest do + @moduledoc """ + Slice 070 through the router, against the console adapter and the fake provider: + AC1 (a paired identity's message creates a session with the adapter's origin and the reply is + streamed back), AC2's automatic half (an unknown sender is answered with the code and nothing + else: no session exists for them), AC3 (`/attach` puts a conversation and the desktop on one + session), AC6 (the rate limit answers rather than dropping), and the commands. + """ + use Trinity.SessionCase + + alias Trinity.Gateways.{Console, Identities, Router} + alias Trinity.LLM.Providers.Fake + + @adapter Console + @conv "c-1" + @user "u-1" + + setup do + start_supervised!(Console) + start_supervised!(Router) + :ok + end + + defp pair!(user \\ @user) do + {:ok, identity, :pending} = Identities.admit("console", user) + {:ok, _} = Identities.pair("console", user, identity.code) + :ok + end + + defp await_text(conversation, expected, timeout \\ 5_000) do + deadline = System.monotonic_time(:millisecond) + timeout + do_await(conversation, expected, deadline) + end + + defp do_await(conversation, expected, deadline) do + text = Console.text(conversation) |> Enum.join("\n") + + cond do + text =~ expected -> + text + + System.monotonic_time(:millisecond) > deadline -> + flunk("never saw #{inspect(expected)} in #{inspect(text)}") + + true -> + Process.sleep(25) && do_await(conversation, expected, deadline) + end + end + + test "AC2 (auto): an unknown sender is shown a code and nothing else happens" do + assert {:error, :pending} = Router.inbound(@adapter, @conv, @user, "hello?") + + [shown] = Console.text(@conv) + {:ok, identity, _} = Identities.admit("console", @user) + assert shown =~ identity.code + assert shown =~ "pair" + + # No session was created and the model was never called for someone not let in. + assert Router.session_of(@adapter, @conv) == nil + assert Sessions.list_sessions() == [] + assert Fake.calls() == 0 + + # The code pairs them, and only then does a message become a message. + assert {:ok, :paired} = Router.inbound(@adapter, @conv, @user, identity.code) + assert Console.text(@conv) |> List.last() =~ "Paired" + end + + test "AC1: a paired identity's message creates a session with the adapter's origin, and the reply streams back" do + :ok = pair!() + Fake.scripts([script_deltas(3, "hello ")]) + + assert {:ok, :placed} = Router.inbound(@adapter, @conv, @user, "say hello") + assert await_text(@conv, "hello hello hello") + + session_id = Router.session_of(@adapter, @conv) + assert is_binary(session_id) + session = Sessions.get_session(session_id) + assert session.origin == "console" + + assert session.origin_ref == %{ + "adapter" => "console", + "conversation" => @conv, + "external_user_id" => @user + } + + # The user's message is in the session's history, so the channel and the desktop agree. + assert Enum.any?( + Sessions.history(session_id), + &(&1.role == "user" and &1.content == "say hello") + ) + end + + test "AC3: /attach puts the conversation and the desktop on one session" do + :ok = pair!() + + {:ok, desktop} = + Sessions.create_session(%{persona_id: Sessions.default_persona().id, title: "desktop"}) + + start_drained(desktop.id) + + assert {:ok, :command} = + Router.inbound(@adapter, @conv, @user, "/attach #{String.slice(desktop.id, 0, 8)}") + + assert Console.text(@conv) |> List.last() =~ "Attached to #{String.slice(desktop.id, 0, 8)}" + assert Router.session_of(@adapter, @conv) == desktop.id + + # A message sent from the desktop (as the LiveView does) reaches the console's stream. + Fake.scripts([script_deltas(2, "shared ")]) + {:ok, _} = Sessions.send_user_message(desktop.id, "from the desktop") + assert await_text(@conv, "shared shared") + + # And a message from the console lands in the same session, not a new one. + Fake.scripts([script_deltas(1, "back ")]) + assert {:ok, :placed} = Router.inbound(@adapter, @conv, @user, "from the channel") + assert await_text(@conv, "back") + assert Router.session_of(@adapter, @conv) == desktop.id + assert length(Sessions.list_sessions()) == 1 + end + + test "AC6: past its rate limit an identity is answered, not dropped, and recovers" do + :ok = pair!() + Application.put_env(:trinity, :gateways, rate_limit: [capacity: 2, per_minute: 60]) + on_exit(fn -> Application.delete_env(:trinity, :gateways) end) + Fake.scripts([script_deltas(1, "ok ")]) + + assert {:ok, :command} = Router.inbound(@adapter, @conv, @user, "/help") + assert {:ok, :command} = Router.inbound(@adapter, @conv, @user, "/help") + assert {:error, :rate_limited} = Router.inbound(@adapter, @conv, @user, "/help") + assert Console.text(@conv) |> List.last() =~ "Too many messages" + + # A different identity has its own bucket. + :ok = pair!("u-2") + assert {:ok, :command} = Router.inbound(@adapter, "c-2", "u-2", "/help") + end + + test "the commands answer without calling the model" do + :ok = pair!() + assert {:ok, :command} = Router.inbound(@adapter, @conv, @user, "/help") + assert Console.text(@conv) |> List.last() =~ "/attach" + + assert {:ok, :command} = Router.inbound(@adapter, @conv, @user, "/nope") + assert Console.text(@conv) |> List.last() =~ "I do not know /nope" + + assert {:ok, :command} = Router.inbound(@adapter, @conv, @user, "/sessions") + assert Console.text(@conv) |> List.last() =~ "No sessions yet" + + assert Fake.calls() == 0 + end + + test "/new unbinds the conversation, so the next message starts a session of its own" do + :ok = pair!() + Fake.scripts([script_deltas(1, "one ")]) + assert {:ok, :placed} = Router.inbound(@adapter, @conv, @user, "first") + assert await_text(@conv, "one") + first = Router.session_of(@adapter, @conv) + + assert {:ok, :command} = Router.inbound(@adapter, @conv, @user, "/new") + assert Router.session_of(@adapter, @conv) == nil + + Fake.scripts([script_deltas(1, "two ")]) + assert {:ok, :placed} = Router.inbound(@adapter, @conv, @user, "second") + assert await_text(@conv, "two") + assert Router.session_of(@adapter, @conv) != first + end + + test "a command that raises costs its own message and does not take the router down" do + :ok = pair!() + router = Process.whereis(Router) + + # `/attach` with something that is not an id: the repo would raise on the cast. + assert {:ok, :command} = Router.inbound(@adapter, @conv, @user, "/attach not-an-id") + assert Console.text(@conv) |> List.last() =~ "No session starts with not-an-id" + assert Process.whereis(Router) == router, "the router was restarted" + + # And the conversation still works afterwards. + Fake.scripts([script_deltas(1, "alive ")]) + assert {:ok, :placed} = Router.inbound(@adapter, @conv, @user, "still there?") + assert await_text(@conv, "alive") + end + + test "a revoked identity is turned away with a reason" do + :ok = pair!() + {:ok, identity} = Identities.revoke(Identities.get("console", @user)) + assert identity.state == "revoked" + + assert {:error, :revoked} = Router.inbound(@adapter, @conv, @user, "let me in") + assert Console.text(@conv) |> List.last() =~ "not allowed" + assert Fake.calls() == 0 + end +end From b0b683f00866a0e0761ac0fc2a484d6820cdb341 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 20:22:29 -0400 Subject: [PATCH 05/10] feat(s070): approvals over chat: the request is rendered into the conversation that raised it, /approve and /deny decide through the gate, and the channel trust cap refuses a tier above its ceiling with a receipt The console adapter recorded a message's reference by position rather than the reference deliver/2 returned, so an edit landed on a different message; found by the approvals suite, where a refusal overwrote an earlier reply. Signed-off-by: Ayla Croft --- lib/trinity/gateways/cap.ex | 83 +++++++++++ lib/trinity/gateways/commands.ex | 59 +++++++- lib/trinity/gateways/console.ex | 44 +++--- lib/trinity/gateways/receipts.ex | 49 +++++++ lib/trinity/gateways/router.ex | 34 ++++- test/trinity/gateways/approvals_test.exs | 173 +++++++++++++++++++++++ 6 files changed, 420 insertions(+), 22 deletions(-) create mode 100644 lib/trinity/gateways/cap.ex create mode 100644 lib/trinity/gateways/receipts.ex create mode 100644 test/trinity/gateways/approvals_test.exs diff --git a/lib/trinity/gateways/cap.ex b/lib/trinity/gateways/cap.ex new file mode 100644 index 0000000..dede483 --- /dev/null +++ b/lib/trinity/gateways/cap.ex @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Cap do + @moduledoc """ + What an approval arriving from a channel may authorise (slice 070; docs/07 "Gateways", the + channel trust cap). + + The allowlist, the pairing and the rate limit control *who may talk to Trinity*. They say + nothing about *what an approval from that channel may allow to happen*, and an approval surface + is exactly as trustworthy as the account behind it: a messaging account is a password reset and + a phone number away from someone else's hands, and the desktop is not. So a tier ceiling is + applied to every decision arriving through a gateway, **after** the permission gate has made its + own decision and never instead of it: the gate can still refuse what the cap would have allowed. + + The default ceiling is `:write`, so `:read`, `:network` and `:write` are answerable from a + paired channel and `:exec` and `:destructive` are not. A capped request is answered, not + dropped: the conversation is told where the decision has to be made, and the refusal is + receipted like any other, because a silent refusal teaches a person that the bot is broken. + + The ceiling is per adapter (`config :trinity, :gateways, caps: %{"console" => :write}`) so a + deployment can raise or lower one channel without touching the others. `Console` is capped like + any other adapter and not exempted: docs/07 was written before this slice and reads "desktop or + console only", meaning the machine's own surfaces; an in-process adapter is still a channel, and + making it the one channel that may approve a destructive effect would be a hole shaped exactly + like the thing the cap exists for. + """ + + alias Trinity.Gateways.Adapter + + @order [:read, :network, :write, :exec, :destructive] + @default_ceiling :write + + @doc "The tiers, weakest first." + @spec order() :: [atom()] + def order, do: @order + + @doc "The ceiling in force for an adapter." + @spec ceiling(module() | String.t()) :: atom() + def ceiling(adapter) when is_atom(adapter) and not is_nil(adapter), + do: adapter |> Adapter.name() |> ceiling() + + def ceiling(adapter_name) when is_binary(adapter_name) do + :trinity + |> Application.get_env(:gateways, []) + |> Keyword.get(:caps, %{}) + |> Map.get(adapter_name, @default_ceiling) + end + + @doc """ + Whether a decision on a request of this tier may be made from this channel. An unknown tier is + refused: a tier this module does not know is not a tier it may wave through. + """ + @spec allows?(module() | String.t(), atom() | String.t() | nil) :: boolean() + def allows?(adapter, tier) do + with {:ok, tier} <- normalise(tier), + {:ok, ceiling} <- normalise(ceiling(adapter)) do + index(tier) <= index(ceiling) + else + :error -> false + end + end + + @doc "Why a request was capped, in words a person in the channel can act on." + @spec refusal(module() | String.t(), atom() | String.t() | nil) :: String.t() + def refusal(adapter, tier) do + "That is a #{tier} request, and this channel may approve up to #{ceiling(adapter)}. " <> + "Decide it on the desktop, on the permissions page." + end + + defp normalise(tier) when is_atom(tier) and not is_nil(tier), + do: normalise(Atom.to_string(tier)) + + defp normalise(tier) when is_binary(tier) do + case Enum.find(@order, &(Atom.to_string(&1) == tier)) do + nil -> :error + found -> {:ok, found} + end + end + + defp normalise(_other), do: :error + + defp index(tier), do: Enum.find_index(@order, &(&1 == tier)) +end diff --git a/lib/trinity/gateways/commands.ex b/lib/trinity/gateways/commands.ex index c1ae021..faa4233 100644 --- a/lib/trinity/gateways/commands.ex +++ b/lib/trinity/gateways/commands.ex @@ -12,7 +12,8 @@ defmodule Trinity.Gateways.Commands do answer a request of that tier at all (docs/07, the channel trust cap). """ - alias Trinity.Gateways.Router + alias Trinity.Gateways.{Adapter, Cap, Format, Receipts, Router} + alias Trinity.Permissions alias Trinity.Sessions @doc "The commands, with one line of help each." @@ -22,6 +23,8 @@ defmodule Trinity.Gateways.Commands do {"/new", "start a fresh session for this conversation"}, {"/sessions", "list the most recent sessions"}, {"/attach ", "watch an existing session from here"}, + {"/approve ", "approve a pending request, up to this channel's tier"}, + {"/deny ", "deny a pending request"}, {"/help", "this list"} ] end @@ -79,12 +82,64 @@ defmodule Trinity.Gateways.Commands do end end + defp run(decision, argument, message, state) when decision in ["/approve", "/deny"] do + {decide(decision, argument, message), state} + end + defp run(unknown, _argument, _message, state), do: {"I do not know #{unknown}. Say /help for the list.", state} + # An approval answered from a channel: the request is found by the short form the person was + # shown, the cap decides whether a channel of this kind may answer a request of that tier at + # all, and only then does `Permissions.decide_request/3` make the decision. The cap is applied + # after the gate's own decision and never instead of it (docs/07); a capped answer is refused + # in words and receipted, because a silent refusal teaches a person the bot is broken. + defp decide(_command, "", _message), do: "Say /approve or /deny ." + + defp decide(command, argument, message) do + case find_pending(argument) do + nil -> + "No pending approval starts with #{argument}." + + approval -> + answer(command, approval, message) + end + end + + defp answer(command, approval, message) do + if Cap.allows?(message.adapter, approval.risk) do + decided(command, approval, message) + else + Receipts.gateway_cap_refusal(approval, message) + Cap.refusal(message.adapter, approval.risk) + end + end + + defp decided("/approve", approval, message) do + case Permissions.decide_request(approval.id, :once, by: decider(message)) do + {:ok, _} -> "Approved #{Format.short(approval.id)}." + {:error, reason} -> "Not decided: #{inspect(reason)}." + end + end + + defp decided("/deny", approval, message) do + case Permissions.decide_request(approval.id, :deny, by: decider(message)) do + {:ok, _} -> "Denied #{Format.short(approval.id)}." + {:error, reason} -> "Not decided: #{inspect(reason)}." + end + end + + # Who decided, as the approval row records it: the channel and the account, never "liveview". + defp decider(message), + do: "gateway:" <> Adapter.name(message.adapter) <> ":" <> message.external_user_id + + defp find_pending(argument) do + Enum.find(Permissions.pending(:all), &String.starts_with?(&1.id, argument)) + end + # `/new` forgets the binding; the next message creates a session as a first message does. defp forget(state, message) do - conv_key = {Trinity.Gateways.Adapter.name(message.adapter), message.conversation} + conv_key = {Adapter.name(message.adapter), message.conversation} case get_in(state.conversations, [conv_key, :session_id]) do nil -> diff --git a/lib/trinity/gateways/console.ex b/lib/trinity/gateways/console.ex index 93be203..60100c8 100644 --- a/lib/trinity/gateways/console.ex +++ b/lib/trinity/gateways/console.ex @@ -62,21 +62,22 @@ defmodule Trinity.Gateways.Console do def clear(conversation), do: GenServer.call(__MODULE__, {:clear, conversation}) @impl GenServer - def handle_call({:deliver, conversation, outbound}, _from, state) do - log = Map.get(state.conversations, conversation, []) - state = put_in(state.conversations[conversation], log ++ [outbound]) - - case outbound do - {:message, _} -> - {:reply, {:ok, state.next_ref}, %{state | next_ref: state.next_ref + 1}} - - _ -> - {:reply, :ok, state} - end + def handle_call({:deliver, conversation, {:message, _} = outbound}, _from, state) do + # The reference is recorded beside the message, not recomputed when the log is read: the + # counter is global to this process and an edit names the reference `deliver/2` returned, so + # renumbering per conversation makes an edit land on a different message (found by the + # approvals suite, where a refusal overwrote an earlier reply). + ref = state.next_ref + state = append(state, conversation, {outbound, ref}) + {:reply, {:ok, ref}, %{state | next_ref: ref + 1}} end + def handle_call({:deliver, conversation, outbound}, _from, state), + do: {:reply, :ok, append(state, conversation, {outbound, nil})} + def handle_call({:delivered, conversation}, _from, state), - do: {:reply, Map.get(state.conversations, conversation, []), state} + do: + {:reply, state.conversations |> Map.get(conversation, []) |> Enum.map(&elem(&1, 0)), state} def handle_call({:text, conversation}, _from, state), do: {:reply, state.conversations |> Map.get(conversation, []) |> visible(), state} @@ -84,19 +85,24 @@ defmodule Trinity.Gateways.Console do def handle_call({:clear, conversation}, _from, state), do: {:reply, :ok, %{state | conversations: Map.delete(state.conversations, conversation)}} - # A message is what was sent; an edit replaces the message it names, which is how a stream - # looks to a person watching the channel rather than reading the log. + defp append(state, conversation, entry) do + log = Map.get(state.conversations, conversation, []) + put_in(state.conversations[conversation], log ++ [entry]) + end + + # A message is what was sent; an edit replaces the message whose reference it names, which is + # how a stream looks to a person watching the channel rather than reading the log. An edit + # naming a message this conversation never had is ignored rather than invented. defp visible(log) do log |> Enum.reduce({[], %{}}, fn - {:message, text}, {order, by_ref} -> - ref = map_size(by_ref) + 1 + {{:message, text}, ref}, {order, by_ref} -> {order ++ [ref], Map.put(by_ref, ref, text)} - {:edit, ref, text}, {order, by_ref} -> - {order, Map.put(by_ref, ref, text)} + {{:edit, ref, text}, _}, {order, by_ref} -> + {order, if(Map.has_key?(by_ref, ref), do: Map.put(by_ref, ref, text), else: by_ref)} - {:typing, _}, acc -> + {{:typing, _}, _}, acc -> acc end) |> then(fn {order, by_ref} -> Enum.map(order, &Map.fetch!(by_ref, &1)) end) diff --git a/lib/trinity/gateways/receipts.ex b/lib/trinity/gateways/receipts.ex new file mode 100644 index 0000000..6c55ead --- /dev/null +++ b/lib/trinity/gateways/receipts.ex @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.Receipts do + @moduledoc """ + What a gateway writes to the receipt chain (slice 070). One thing, for now: a decision that a + channel was not allowed to make. docs/07 says a capped request is refused rather than dropped, + and a refusal nobody can find afterwards is a drop with a message attached, so it is receipted + on the session's own chain beside the decisions the gate writes there. + + The channel and the account that tried are named; nothing the person typed is copied, because + the text of a message to a bot is not evidence about an approval. + """ + + alias Trinity.Gateways.{Adapter, Cap} + alias Trinity.Permissions.Approval + alias Trinity.Receipts + + require Logger + + @doc "Receipts a decision the channel's cap refused to make." + @spec gateway_cap_refusal(Approval.t(), map()) :: :ok + def gateway_cap_refusal(%Approval{} = approval, message) do + adapter = Adapter.name(message.adapter) + + Receipts.append(Receipts.session_scope(approval.session_id), %{ + kind: "decision", + subject: %{ + "phase" => "gateway_cap", + "adapter" => adapter, + "external_user_id" => message.external_user_id, + "conversation" => message.conversation, + "tool" => approval.tool, + "risk" => approval.risk, + "origin" => "gateway" + }, + decision: %{ + "outcome" => "deny", + "basis" => "channel_cap", + "reason" => "#{approval.risk} is above this channel's ceiling #{Cap.ceiling(adapter)}" + }, + subject_ref: "approval:" <> approval.id, + meta: %{} + }) + + :ok + rescue + error -> Logger.warning("gateway: cap refusal not receipted: #{Exception.message(error)}") + end +end diff --git a/lib/trinity/gateways/router.ex b/lib/trinity/gateways/router.ex index 97c3813..a801b57 100644 --- a/lib/trinity/gateways/router.ex +++ b/lib/trinity/gateways/router.ex @@ -21,7 +21,8 @@ defmodule Trinity.Gateways.Router do """ use GenServer - alias Trinity.Gateways.{Adapter, Commands, Format, Identities} + alias Trinity.Gateways.{Adapter, Cap, Commands, Format, Identities} + alias Trinity.Permissions alias Trinity.Sessions require Logger @@ -117,6 +118,12 @@ defmodule Trinity.Gateways.Router do end end + def handle_info({:approval, :requested, approval}, state) do + {:noreply, render_approval(approval, state)} + end + + def handle_info({:approval, :decided, _approval}, state), do: {:noreply, state} + def handle_info({:flush, conv_key}, state), do: {:noreply, flush(conv_key, state)} def handle_info(_other, state), do: {:noreply, state} @@ -240,6 +247,9 @@ defmodule Trinity.Gateways.Router do def bind_state(state, adapter, conversation, session_id) do conv_key = key(adapter, conversation) :ok = Sessions.subscribe(session_id) + # The approvals a turn in this session raises are rendered into the conversation that + # started it, so the person who asked is the person who answers (docs/07's approval channel). + :ok = Permissions.subscribe(session_id) conversation_state = %{ adapter: adapter, @@ -358,6 +368,28 @@ defmodule Trinity.Gateways.Router do end end + # A request is rendered into the conversation bound to its session, and nowhere else: an + # approval belongs to the surface that asked for it. A tier this channel may not answer is said + # so here, so the person is not left typing /approve at something that will refuse them. + defp render_approval(approval, state) do + case Map.get(state.sessions, approval.session_id) do + nil -> + state + + conv_key -> + conv = Map.fetch!(state.conversations, conv_key) + {:message, text} = conv.adapter.render_approval(approval, conv.adapter.capabilities()) + + text = + if Cap.allows?(conv.adapter, approval.risk), + do: text, + else: text <> "\n\n" <> Cap.refusal(conv.adapter, approval.risk) + + _ = conv.adapter.deliver(conv.conversation, {:message, text}) + state + end + end + ## Rate limiting: a token bucket per identity, in this process's own state defp take_token(state, message) do diff --git a/test/trinity/gateways/approvals_test.exs b/test/trinity/gateways/approvals_test.exs new file mode 100644 index 0000000..da816d5 --- /dev/null +++ b/test/trinity/gateways/approvals_test.exs @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.ApprovalsTest do + @moduledoc """ + Slice 070, AC4 and AC8: an approval raised by a turn that started in a channel is rendered into + that channel and answered there with `/approve`; and a request above the channel's tier ceiling + is refused in words, receipted, and left pending for the desktop, with the gate never asked. + The cap is applied after the gate's own decision and never instead of it (docs/07). + """ + use Trinity.SessionCase + + alias Trinity.Gateways.{Cap, Console, Identities, Router} + alias Trinity.LLM.Providers.Fake + alias Trinity.Permissions + alias Trinity.Permissions.Approval + alias Trinity.Receipts + + @adapter Console + @conv "c-approve" + @user "u-1" + + setup do + start_supervised!(Console) + start_supervised!(Router) + {:ok, identity, :pending} = Identities.admit("console", @user) + {:ok, _} = Identities.pair("console", @user, identity.code) + :ok + end + + defp session_for_conversation do + Fake.scripts([script_deltas(1, "hi ")]) + {:ok, :placed} = Router.inbound(@adapter, @conv, @user, "start a session") + await(fn -> Router.session_of(@adapter, @conv) end) + end + + defp await(fun, timeout \\ 5_000) do + deadline = System.monotonic_time(:millisecond) + timeout + do_await(fun, deadline) + end + + defp do_await(fun, deadline) do + case fun.() do + nil -> + if System.monotonic_time(:millisecond) > deadline, + do: flunk("never became truthy"), + else: Process.sleep(25) && do_await(fun, deadline) + + value -> + value + end + end + + # "" and not nil when the channel has nothing yet: `nil =~ "x"` raises, which turns a wait into + # an immediate failure and hides the race it was written to wait out. + defp last_text, do: Console.text(@conv) |> List.last() || "" + + # Everything the channel has been shown. A wait looks here and not at the last message alone: + # the turn's reply and an approval raised during it arrive in either order, and a wait that + # reads only the last one waits forever for whichever came first. + defp shown, do: Console.text(@conv) |> Enum.join("\n") + + defp await_shown(fragment), do: await(fn -> if shown() =~ fragment, do: :shown end) + + test "AC4: a request raised in a gateway session is rendered there and /approve decides it" do + session_id = session_for_conversation() + + {:ok, %Approval{id: id}} = + Permissions.request_approval(session_id, "write_note", %{"path" => "notes/a.md"}, + risk: :write + ) + + await_shown("Approval needed: write_note") + assert shown() =~ "/approve #{String.slice(id, 0, 8)}" + + assert {:ok, :command} = + Router.inbound(@adapter, @conv, @user, "/approve #{String.slice(id, 0, 8)}") + + assert last_text() =~ "Approved #{String.slice(id, 0, 8)}" + + approval = Permissions.get_approval(id) + assert approval.status == "allowed" and approval.decision == "once" + assert approval.decided_by == "gateway:console:#{@user}" + end + + test "/deny from the channel denies it, and an unknown id says so" do + session_id = session_for_conversation() + + {:ok, %Approval{id: id}} = + Permissions.request_approval(session_id, "fetch", %{"url" => "https://example.com"}, + risk: :network + ) + + await_shown("Approval needed: fetch") + + assert {:ok, :command} = + Router.inbound(@adapter, @conv, @user, "/deny #{String.slice(id, 0, 8)}") + + assert last_text() =~ "Denied" + assert %Approval{status: "denied"} = Permissions.get_approval(id) + + assert {:ok, :command} = Router.inbound(@adapter, @conv, @user, "/approve zzzzzzzz") + assert last_text() =~ "No pending approval starts with zzzzzzzz" + end + + test "AC8: a destructive request is refused by the cap, receipted, and left for the desktop" do + session_id = session_for_conversation() + assert Cap.ceiling(@adapter) == :write + refute Cap.allows?(@adapter, :destructive) + + {:ok, %Approval{id: id}} = + Permissions.request_approval(session_id, "delete_tree", %{"path" => "/tmp/x"}, + risk: :destructive + ) + + # It is rendered, and the render already says the channel cannot answer it. + await_shown("Approval needed: delete_tree") + assert shown() =~ "may approve up to write" + assert shown() =~ "permissions page" + + # And trying anyway is refused in words rather than dropped. + assert {:ok, :command} = + Router.inbound(@adapter, @conv, @user, "/approve #{String.slice(id, 0, 8)}") + + assert last_text() =~ "That is a destructive request" + + # The gate was never asked: the request is still pending for the desktop. + assert %Approval{status: "pending", decided_by: nil} = Permissions.get_approval(id) + + # The refusal is a receipt on the session's chain, naming the channel and the account. + scope = Receipts.session_scope(session_id) + + refusal = + await(fn -> + Enum.find(Receipts.list(scope), &(&1.subject["phase"] == "gateway_cap")) + end) + + assert refusal.subject["adapter"] == "console" + assert refusal.subject["external_user_id"] == @user + assert refusal.subject["risk"] == "destructive" + assert Jason.decode!(refusal.signed_payload)["decision"]["basis"] == "channel_cap" + on_exit(fn -> Receipts.stop_writer(scope) end) + end + + test "the ceiling is per adapter and configurable, and an unknown tier is refused" do + assert Cap.allows?(@adapter, :read) + assert Cap.allows?(@adapter, "write") + refute Cap.allows?(@adapter, :exec) + + Application.put_env(:trinity, :gateways, caps: %{"console" => :destructive}) + on_exit(fn -> Application.delete_env(:trinity, :gateways) end) + assert Cap.allows?(@adapter, :destructive) + + # A tier this module does not know is not a tier it waves through. + refute Cap.allows?(@adapter, :root) + refute Cap.allows?(@adapter, nil) + end + + test "a raised request in someone else's session is not rendered into this channel" do + _mine = session_for_conversation() + + {:ok, other} = + Sessions.create_session(%{persona_id: Sessions.default_persona().id, title: "desktop"}) + + # Wait for this conversation's own turn to finish first, or "nothing new arrived" is only a + # statement about how fast the assertion ran. + await_shown("hi") + before = Console.text(@conv) + + {:ok, _} = Permissions.request_approval(other.id, "write_note", %{}, risk: :write) + Process.sleep(100) + assert Console.text(@conv) == before + end +end From 07949cff3f1e5ddfb9ed0860e5ed197e5c3f0211 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 20:24:35 -0400 Subject: [PATCH 06/10] feat(s070): cron delivery to a channel, and the fifty-conversation stress: each in its own session, none shown another's message Signed-off-by: Ayla Croft --- config/config.exs | 4 ++ lib/trinity/scheduler.ex | 15 +++- lib/trinity/scheduler/delivery/gateway.ex | 66 +++++++++++++++++ test/trinity/gateways/delivery_test.exs | 74 +++++++++++++++++++ test/trinity/gateways/stress_test.exs | 87 +++++++++++++++++++++++ 5 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 lib/trinity/scheduler/delivery/gateway.ex create mode 100644 test/trinity/gateways/delivery_test.exs create mode 100644 test/trinity/gateways/stress_test.exs diff --git a/config/config.exs b/config/config.exs index 99b5214..dcb2c8d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -214,3 +214,7 @@ config :phoenix, :json_library, Jason # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" + +# Slice 070: a scheduled run can be delivered to a gateway conversation; `deliver_to`'s "kind" +# names the implementation (slice 050's `Trinity.Scheduler.Delivery.for/1`). +config :trinity, :deliveries, %{"gateway" => Trinity.Scheduler.Delivery.Gateway} diff --git a/lib/trinity/scheduler.ex b/lib/trinity/scheduler.ex index e282bee..9ebe877 100644 --- a/lib/trinity/scheduler.ex +++ b/lib/trinity/scheduler.ex @@ -15,8 +15,19 @@ defmodule Trinity.Scheduler do `tasks` carries `{:task_run, %Run{}}` when a run is delivered. """ use Boundary, - deps: [Trinity, Trinity.Sessions, Trinity.LLM], - exports: [Task, Run, Delivery, Delivery.Desktop, Parse, Workers.Tick, Workers.RunTask] + # Slice 070: a run can be delivered to a channel, which is the one thing the scheduler asks + # of the gateway layer (docs/01's row said so when 050 was built). + deps: [Trinity, Trinity.Sessions, Trinity.LLM, Trinity.Gateways], + exports: [ + Task, + Run, + Delivery, + Delivery.Desktop, + Delivery.Gateway, + Parse, + Workers.Tick, + Workers.RunTask + ] import Ecto.Query, only: [from: 2] diff --git a/lib/trinity/scheduler/delivery/gateway.ex b/lib/trinity/scheduler/delivery/gateway.ex new file mode 100644 index 0000000..9df85bc --- /dev/null +++ b/lib/trinity/scheduler/delivery/gateway.ex @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Scheduler.Delivery.Gateway do + @moduledoc """ + A finished run delivered to a channel (slice 070, the second implementation of slice 050's + `Trinity.Scheduler.Delivery`). A task names its destination in `deliver_to`: + + %{"kind" => "gateway", "adapter" => "console", "conversation" => "c-1"} + + The run's summary is sent through the adapter's own formatting, so a long one is chunked to the + channel's limit like any other message. The run is marked delivered and broadcast as the + desktop delivery does, so the tasks page still shows it: a result that went to a channel is not + a result the desktop should forget. + + A destination naming an adapter that is not configured is an error rather than a silent drop, + and the run keeps its undelivered state for the page to show. + """ + @behaviour Trinity.Scheduler.Delivery + + alias Trinity.Gateways + alias Trinity.Gateways.Adapter + alias Trinity.Scheduler + alias Trinity.Scheduler.{Run, Task} + + @impl true + def deliver(%Run{} = run, %Task{deliver_to: destination} = task) do + with {:ok, adapter} <- adapter(destination), + {:ok, conversation} <- fetch(destination, "conversation") do + send_summary(adapter, conversation, run, task) + mark(run) + end + end + + defp send_summary(adapter, conversation, run, task) do + text = "#{task.name}: #{run.summary || "(no summary)"}" + + for chunk <- adapter.format(text, adapter.capabilities()) do + adapter.deliver(conversation, {:message, chunk}) + end + end + + defp mark(run) do + with {:ok, run} <- Scheduler.update_run(run, %{delivered_at: DateTime.utc_now()}) do + Scheduler.broadcast(run) + {:ok, run} + end + end + + # The adapter is named, never handed in: a task row is data, and a module name in a row that + # the scheduler would call is a way to run any module by writing a row. + defp adapter(destination) do + with {:ok, name} <- fetch(destination, "adapter") do + case Enum.find(Gateways.adapters(), &(Adapter.name(&1) == name)) do + nil -> {:error, {:unknown_adapter, name}} + adapter -> {:ok, adapter} + end + end + end + + defp fetch(destination, key) do + case Map.get(destination, key) do + value when is_binary(value) and value != "" -> {:ok, value} + _ -> {:error, {:missing, key}} + end + end +end diff --git a/test/trinity/gateways/delivery_test.exs b/test/trinity/gateways/delivery_test.exs new file mode 100644 index 0000000..21d79ca --- /dev/null +++ b/test/trinity/gateways/delivery_test.exs @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.DeliveryTest do + @moduledoc """ + Slice 070 AC5: a scheduled run delivered to a channel. The summary reaches the conversation + through the adapter's own formatting, the run is marked delivered and broadcast as the desktop + delivery does, and a destination naming an adapter that is not configured is an error rather + than a silent drop. + """ + use Trinity.DataCase, async: false + + alias Trinity.Gateways.Console + alias Trinity.Scheduler + alias Trinity.Scheduler.Delivery + + setup do + start_supervised!(Console) + Application.put_env(:trinity, :gateways, adapters: [Console]) + on_exit(fn -> Application.delete_env(:trinity, :gateways) end) + :ok + end + + defp task_to(destination) do + {:ok, task} = + Scheduler.create_task(%{ + name: "morning digest", + kind: "cron", + schedule: "0 9 * * *", + prompt: "summarise", + deliver_to: destination + }) + + task + end + + test "AC5: the run's summary reaches the conversation, and the run is marked delivered" do + task = task_to(%{"kind" => "gateway", "adapter" => "console", "conversation" => "c-cron"}) + {:ok, run} = Scheduler.run_now(task) + {:ok, run} = Scheduler.update_run(run, %{status: "ok", summary: "three things happened"}) + + # The scheduler picks the implementation from the row, not from a module in it. + assert Delivery.for(task) == Delivery.Gateway + assert {:ok, delivered} = Delivery.for(task).deliver(run, task) + + assert Console.text("c-cron") == ["morning digest: three things happened"] + assert delivered.delivered_at != nil + assert Scheduler.get_run(run.id).delivered_at != nil + end + + test "a summary longer than the channel's limit arrives in chunks, not truncated" do + task = task_to(%{"kind" => "gateway", "adapter" => "console", "conversation" => "c-long"}) + {:ok, run} = Scheduler.run_now(task) + summary = String.duplicate("word ", 1_500) + {:ok, run} = Scheduler.update_run(run, %{status: "ok", summary: summary}) + + assert {:ok, _} = Delivery.Gateway.deliver(run, task) + chunks = Console.text("c-long") + assert length(chunks) > 1 + assert Enum.all?(chunks, &(String.length(&1) <= Console.capabilities().max_length)) + assert chunks |> Enum.join(" ") =~ "word word" + end + + test "an unknown adapter or a missing conversation is an error, and nothing is marked delivered" do + task = task_to(%{"kind" => "gateway", "adapter" => "telegram", "conversation" => "c-1"}) + {:ok, run} = Scheduler.run_now(task) + {:ok, run} = Scheduler.update_run(run, %{status: "ok", summary: "x"}) + + assert {:error, {:unknown_adapter, "telegram"}} = Delivery.Gateway.deliver(run, task) + assert Scheduler.get_run(run.id).delivered_at == nil + + incomplete = task_to(%{"kind" => "gateway", "adapter" => "console"}) + assert {:error, {:missing, "conversation"}} = Delivery.Gateway.deliver(run, incomplete) + end +end diff --git a/test/trinity/gateways/stress_test.exs b/test/trinity/gateways/stress_test.exs new file mode 100644 index 0000000..c150c61 --- /dev/null +++ b/test/trinity/gateways/stress_test.exs @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.Gateways.StressTest do + @moduledoc """ + Slice 070 AC7: fifty console conversations, each a paired identity of its own, all talking at + once under the fake provider. Every one gets its reply, every one has a session of its own, and + `PRAGMA integrity_check` is `ok` afterwards (on the Postgres leg the pragma does not apply and + the rest of the test is the whole of it). The router is one process, so this is also what says + it does not serialise into uselessness or leak a conversation's stream into another's. + """ + use Trinity.SessionCase + + alias Trinity.Gateways.{Console, Identities, Router} + alias Trinity.LLM.Providers.Fake + + @conversations 50 + + @tag timeout: 300_000 + test "AC7: fifty conversations complete, each in its own session, and the database is intact" do + start_supervised!(Console) + start_supervised!(Router) + Application.put_env(:trinity, :gateways, rate_limit: [capacity: 5, per_minute: 600]) + on_exit(fn -> Application.delete_env(:trinity, :gateways) end) + Fake.scripts([script_deltas(2, "reply ")]) + + identities = + for n <- 1..@conversations do + user = "u-#{n}" + {:ok, identity, :pending} = Identities.admit("console", user) + {:ok, _} = Identities.pair("console", user, identity.code) + {"c-#{n}", user} + end + + results = + identities + |> Task.async_stream( + fn {conversation, user} -> + Router.inbound(Console, conversation, user, "hello from #{conversation}") + end, + max_concurrency: @conversations, + timeout: 60_000 + ) + |> Enum.map(fn {:ok, result} -> result end) + + assert Enum.all?(results, &(&1 == {:ok, :placed})), "not every message was placed" + + # Every conversation is answered, and answered in its own channel. + for {conversation, _user} <- identities do + text = await_text(conversation) + assert text =~ "reply reply" + refute text =~ "hello from", "a conversation was shown another's message" + end + + # One session per conversation, each with the right origin, and nothing shared. + session_ids = + for {conversation, _} <- identities, do: Router.session_of(Console, conversation) + + assert length(Enum.uniq(session_ids)) == @conversations + assert Enum.all?(session_ids, &is_binary/1) + + for id <- session_ids do + session = Sessions.get_session(id) + assert session.origin == "console" + end + + # The repo's own adapter, as slice 010's stress test asks it: a configuration key can be set + # to something the repo is not. + if Trinity.Repo.__adapter__() == Ecto.Adapters.SQLite3 do + assert %{rows: [["ok"]]} = Trinity.Repo.query!("PRAGMA integrity_check") + end + end + + defp await_text(conversation, timeout \\ 60_000) do + deadline = System.monotonic_time(:millisecond) + timeout + do_await(conversation, deadline) + end + + defp do_await(conversation, deadline) do + text = Console.text(conversation) |> Enum.join("\n") + + cond do + text =~ "reply" -> text + System.monotonic_time(:millisecond) > deadline -> flunk("#{conversation} never answered") + true -> Process.sleep(50) && do_await(conversation, deadline) + end + end +end From 0b1e70bf40c9f7456e75ad4acfe018ab446d34fb Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 20:25:16 -0400 Subject: [PATCH 07/10] test(s070): the approvals suite waits the opening turn out; two assertions on 'the last thing shown' were racing the reply and failed only when the suite ran whole Signed-off-by: Ayla Croft --- test/trinity/gateways/approvals_test.exs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/trinity/gateways/approvals_test.exs b/test/trinity/gateways/approvals_test.exs index da816d5..82cd8bd 100644 --- a/test/trinity/gateways/approvals_test.exs +++ b/test/trinity/gateways/approvals_test.exs @@ -27,10 +27,15 @@ defmodule Trinity.Gateways.ApprovalsTest do :ok end + # The opening turn is waited out here, not just started: its reply lands asynchronously, and a + # later assertion on "the last thing the channel was shown" is otherwise racing it. Two tests + # failed exactly that way when the suite ran as a whole and passed when run alone. defp session_for_conversation do Fake.scripts([script_deltas(1, "hi ")]) {:ok, :placed} = Router.inbound(@adapter, @conv, @user, "start a session") - await(fn -> Router.session_of(@adapter, @conv) end) + session_id = await(fn -> Router.session_of(@adapter, @conv) end) + await_shown("hi") + session_id end defp await(fun, timeout \\ 5_000) do @@ -75,7 +80,7 @@ defmodule Trinity.Gateways.ApprovalsTest do assert {:ok, :command} = Router.inbound(@adapter, @conv, @user, "/approve #{String.slice(id, 0, 8)}") - assert last_text() =~ "Approved #{String.slice(id, 0, 8)}" + assert shown() =~ "Approved #{String.slice(id, 0, 8)}" approval = Permissions.get_approval(id) assert approval.status == "allowed" and approval.decision == "once" @@ -95,11 +100,11 @@ defmodule Trinity.Gateways.ApprovalsTest do assert {:ok, :command} = Router.inbound(@adapter, @conv, @user, "/deny #{String.slice(id, 0, 8)}") - assert last_text() =~ "Denied" + assert shown() =~ "Denied" assert %Approval{status: "denied"} = Permissions.get_approval(id) assert {:ok, :command} = Router.inbound(@adapter, @conv, @user, "/approve zzzzzzzz") - assert last_text() =~ "No pending approval starts with zzzzzzzz" + assert shown() =~ "No pending approval starts with zzzzzzzz" end test "AC8: a destructive request is refused by the cap, receipted, and left for the desktop" do @@ -121,7 +126,7 @@ defmodule Trinity.Gateways.ApprovalsTest do assert {:ok, :command} = Router.inbound(@adapter, @conv, @user, "/approve #{String.slice(id, 0, 8)}") - assert last_text() =~ "That is a destructive request" + assert shown() =~ "That is a destructive request" # The gate was never asked: the request is still pending for the desktop. assert %Approval{status: "pending", decided_by: nil} = Permissions.get_approval(id) @@ -163,7 +168,6 @@ defmodule Trinity.Gateways.ApprovalsTest do # Wait for this conversation's own turn to finish first, or "nothing new arrived" is only a # statement about how fast the assertion ran. - await_shown("hi") before = Console.text(@conv) {:ok, _} = Permissions.request_approval(other.id, "write_note", %{}, risk: :write) From aea5e2060043d055265997e7eb8b26eed1f9868f Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 20:29:46 -0400 Subject: [PATCH 08/10] feat(s070): the /gateways page (channels with the tier each may approve, pairing codes, identities to allow or revoke) and mix trinity.console Signed-off-by: Ayla Croft --- lib/mix/tasks/trinity.console.ex | 94 +++++++++ lib/trinity.ex | 10 + lib/trinity/gateways.ex | 2 +- lib/trinity_web/live/gateways_live.ex | 204 +++++++++++++++++++ lib/trinity_web/router.ex | 2 + test/trinity_web/live/gateways_live_test.exs | 63 ++++++ 6 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 lib/mix/tasks/trinity.console.ex create mode 100644 lib/trinity_web/live/gateways_live.ex create mode 100644 test/trinity_web/live/gateways_live_test.exs diff --git a/lib/mix/tasks/trinity.console.ex b/lib/mix/tasks/trinity.console.ex new file mode 100644 index 0000000..2126922 --- /dev/null +++ b/lib/mix/tasks/trinity.console.ex @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Console do + @shortdoc "Talks to Trinity from this terminal through the console gateway" + @moduledoc """ + Slice 070. Starts the application and talks to it through `Trinity.Gateways.Console`, the + in-process adapter, so the gateway layer can be used and watched without a platform account: + + mix trinity.console + + It is a real channel and not a back door. The first message is answered with a pairing code, + as any unknown sender's is; the code is shown on the desktop's `/gateways` page, and typing it + here pairs this terminal. Every slash command works (`/help` lists them), an approval raised by + a turn appears here, and the channel's tier ceiling applies to `/approve` exactly as it would + on a messaging platform: an `exec` or `destructive` request is decided on the desktop. + + Two things to know, as with `mix trinity.mcp.stdio`: this is a whole Trinity and the data + directory admits one at a time (`Trinity.DataDir.Lock`), so run it when the desktop does not, + or point it at its own directory with `TRINITY_DATA_DIR`. Ctrl-C twice leaves. + """ + use Boundary, classify_to: Trinity.Gateways + use Mix.Task + + alias Trinity.Gateways.{Console, Router} + + @conversation "terminal" + + @impl Mix.Task + def run(argv) do + Mix.Task.run("app.start") + user = Enum.at(argv, 0, System.get_env("USER") || "owner") + {:ok, _} = ensure_started(Console) + {:ok, _} = ensure_started(Router) + + IO.puts("Trinity console. The first message will be answered with a pairing code.") + IO.puts("Say /help for the commands, or Ctrl-C twice to leave.\n") + loop(user) + end + + defp ensure_started(module) do + case module.start_link([]) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + other -> other + end + end + + defp loop(user) do + case IO.gets("> ") do + :eof -> + :ok + + {:error, reason} -> + Mix.shell().error("console: #{inspect(reason)}") + + line -> + line |> String.trim() |> send_line(user) + loop(user) + end + end + + defp send_line("", _user), do: :ok + + defp send_line(text, user) do + before = length(Console.delivered(@conversation)) + _ = Router.inbound(Console, @conversation, user, text) + # The reply streams, so the terminal waits a moment for what the channel was shown rather + # than printing nothing and looking broken. + print_new(before, 0) + end + + defp print_new(before, waited) when waited < 30_000 do + delivered = Console.delivered(@conversation) + + if length(delivered) > before do + Process.sleep(200) + Console.text(@conversation) |> Enum.drop(visible_before(before)) |> Enum.each(&IO.puts/1) + else + Process.sleep(100) + print_new(before, waited + 100) + end + end + + defp print_new(_before, _waited), do: IO.puts("(no answer yet)") + + # How many visible messages the log held before this turn: an edit replaces a message rather + # than adding one, so counting the log is not counting what a person has seen. + defp visible_before(before) do + @conversation + |> Console.delivered() + |> Enum.take(before) + |> Enum.count(&match?({:message, _}, &1)) + end +end diff --git a/lib/trinity.ex b/lib/trinity.ex index 98ff881..f41399a 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -70,6 +70,16 @@ defmodule Trinity do Skills.Manager, Skills.Learn, # Slice 050: the tasks page reads and writes the scheduler. + # Slice 070: the /gateways page reads the channels, the identities and the cap; the + # scheduler's gateway delivery reaches the adapter through these too. `Gateways` is a + # boundary of its own under this one, as `Scheduler` is. + Gateways, + Gateways.Adapter, + Gateways.Cap, + Gateways.Console, + Gateways.Identities, + Gateways.Identity, + Gateways.Router, Scheduler, Scheduler.Task, Scheduler.Run, diff --git a/lib/trinity/gateways.ex b/lib/trinity/gateways.ex index 1afb8b5..10fcd82 100644 --- a/lib/trinity/gateways.ex +++ b/lib/trinity/gateways.ex @@ -14,7 +14,7 @@ defmodule Trinity.Gateways do """ use Boundary, deps: [Trinity, Trinity.Sessions], - exports: [Adapter, Console, Format] + exports: [Adapter, Cap, Console, Format, Identities, Identity, Router] @doc "The adapter modules in force." @spec adapters() :: [module()] diff --git a/lib/trinity_web/live/gateways_live.ex b/lib/trinity_web/live/gateways_live.ex new file mode 100644 index 0000000..2148f8d --- /dev/null +++ b/lib/trinity_web/live/gateways_live.ex @@ -0,0 +1,204 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.GatewaysLive do + @moduledoc """ + `/gateways` (slice 070): the channels Trinity can be reached from, who is waiting to be paired, + and who has been let in. + + The pairing code is shown here and nowhere else, which is the whole of the pairing proof: a + sender who can read this page is at the owner's desktop. Each identity can be allowed without a + code or revoked, and a revoked row stays visible rather than disappearing, so turning someone + away is a thing the page records rather than a thing it forgets. The tier ceiling each adapter + carries is shown beside it, because what a channel may approve is the question this page is + really about (docs/07, the channel trust cap). + """ + use TrinityWeb, :live_view + + alias Trinity.Gateways + alias Trinity.Gateways.{Adapter, Cap, Identities} + + @refresh_ms 2_000 + + @impl true + def mount(_params, _session, socket) do + if connected?(socket), do: Process.send_after(self(), :refresh, @refresh_ms) + {:ok, socket |> assign(page_title: gettext("Gateways")) |> load()} + end + + defp load(socket) do + assign(socket, + adapters: adapters(), + identities: Identities.list(), + ttl_minutes: div(Identities.code_ttl_s(), 60) + ) + end + + defp adapters do + for adapter <- Gateways.adapters() do + %{ + module: adapter, + name: Adapter.name(adapter), + running?: is_pid(Process.whereis(adapter)), + ceiling: Cap.ceiling(adapter), + capabilities: adapter.capabilities() + } + end + end + + @impl true + def handle_info(:refresh, socket) do + Process.send_after(self(), :refresh, @refresh_ms) + {:noreply, load(socket)} + end + + @impl true + def handle_event("allow", %{"id" => id}, socket), + do: {:noreply, act(socket, id, &Identities.allow/1)} + + def handle_event("revoke", %{"id" => id}, socket), + do: {:noreply, act(socket, id, &Identities.revoke/1)} + + defp act(socket, id, fun) do + case Enum.find(socket.assigns.identities, &(&1.id == id)) do + nil -> put_flash(socket, :error, gettext("That identity is gone.")) + identity -> with({:ok, _} <- fun.(identity), do: load(socket)) + end + end + + @impl true + def render(assigns) do + ~H""" + + <:bar> + {gettext("Gateways")} + {length(@identities)} + +
+
+

{gettext("Channels")}

+

+ {gettext( + "No gateway is configured. A channel is a module in config :trinity, :gateways, adapters: [...]; the console adapter ships with Trinity and is what mix trinity.console talks to." + )} +

+
    +
  • + {adapter.name} + + {if adapter.running?, do: gettext("running"), else: gettext("not started")} + + + {gettext("approves up to")} + {adapter.ceiling} + + + {gettext("max")} + {adapter.capabilities.max_length} + +
  • +
+

+ {gettext( + "A channel may approve up to its ceiling and no further: an exec or destructive request is decided on the desktop, on the permissions page. The gate still decides every call whatever the channel." + )} +

+
+ +
+

{gettext("Waiting to pair")}

+

+ {gettext("Nobody is waiting. An unknown sender is shown a code here when they write.")} +

+
    +
  • + {identity.code} + + {identity.adapter}:{identity.external_user_id} + + + {identity.display_name} + + + + {gettext("expires in %{n} minutes", n: @ttl_minutes)} + + + +
  • +
+

+ {gettext( + "Send the code from the channel to pair, or allow it here. Until then that sender gets the pairing prompt and nothing else: no session, no model." + )} +

+
+ +
+

{gettext("Identities")}

+ + + + + + + + + + +
{identity.adapter}{identity.external_user_id}{identity.state}{identity.display_name} + + +
+

+ {gettext("No identity has written yet.")} +

+
+
+
+ """ + end + + defp pending(identities), do: Enum.filter(identities, &(&1.state == "pending" and &1.code)) +end diff --git a/lib/trinity_web/router.ex b/lib/trinity_web/router.ex index 229c166..e55bc1f 100644 --- a/lib/trinity_web/router.ex +++ b/lib/trinity_web/router.ex @@ -57,6 +57,8 @@ defmodule TrinityWeb.Router do live "/skills", SkillsLive, :index # Slice 060: the MCP servers and their health. live "/mcp", MCPLive, :index + # Slice 070: the channels Trinity can be reached from, and who may. + live "/gateways", GatewaysLive, :index # Slice 050: scheduled tasks, their runs and the results to read. live "/tasks", TasksLive, :index # Slice 024: a session's receipt chain, and the boot receipt of this run. diff --git a/test/trinity_web/live/gateways_live_test.exs b/test/trinity_web/live/gateways_live_test.exs new file mode 100644 index 0000000..5fa070c --- /dev/null +++ b/test/trinity_web/live/gateways_live_test.exs @@ -0,0 +1,63 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule TrinityWeb.GatewaysLiveTest do + @moduledoc """ + Slice 070, AC9's automatic half: `/gateways` lists the configured channels with the tier each + may approve, shows a waiting identity's pairing code, and allows or revokes one. The code is + shown on this page and nowhere else, which is what makes reading it the proof of pairing. + """ + use TrinityWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + alias Trinity.Gateways.{Console, Identities} + + setup do + start_supervised!(Console) + Application.put_env(:trinity, :gateways, adapters: [Console]) + on_exit(fn -> Application.delete_env(:trinity, :gateways) end) + :ok + end + + test "the page lists the channel, whether it runs, and the tier it may approve", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/gateways") + assert html =~ "console" + assert html =~ "running" + assert html =~ "approves up to" + assert html =~ "write" + assert html =~ "permissions page" + end + + test "a waiting identity's code is shown here, and allow pairs it without a code", %{conn: conn} do + {:ok, identity, :pending} = Identities.admit("console", "u-1", display_name: "Someone") + + {:ok, view, html} = live(conn, ~p"/gateways") + assert html =~ identity.code + assert html =~ "console:u-1" + assert html =~ "Someone" + + view |> element("#identity-#{identity.id} button", "allow") |> render_click() + assert Identities.get("console", "u-1").state == "paired" + refute render(view) =~ identity.code + end + + test "revoking keeps the row and shows it as revoked", %{conn: conn} do + {:ok, identity, :pending} = Identities.admit("console", "u-2") + + {:ok, view, _html} = live(conn, ~p"/gateways") + view |> element("#identity-#{identity.id} button", "revoke") |> render_click() + + assert Identities.get("console", "u-2").state == "revoked" + html = render(view) + assert html =~ "u-2" + assert html =~ "revoked" + end + + test "with no identity and no adapter configured the page says so", %{conn: conn} do + Application.put_env(:trinity, :gateways, adapters: []) + {:ok, _view, html} = live(conn, ~p"/gateways") + assert html =~ "No gateway is configured" + assert html =~ "Nobody is waiting" + assert html =~ "No identity has written yet" + end +end From 53614250c856b28fe1bc8cf93b312f07854086a8 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 20:38:09 -0400 Subject: [PATCH 09/10] docs(s070): the deviations, the findings the suite caught, and the follow-ups; docs 01, 05, 07 and the README as built; PROOF with the evidence and the two manual artefacts Signed-off-by: Ayla Croft --- README.md | 12 +- docs/01-architecture.md | 5 +- docs/05-data-model.md | 22 +- docs/07-security-model.md | 36 ++- scripts/dev_gateways.sh | 76 ++++++ slices/070-gateway-core/NOTES.md | 60 +++++ slices/070-gateway-core/PROOF.md | 237 ++++++++++++++++++ .../proof/ac2-pairing-transcript.txt | 35 +++ .../proof/ac9-gateways-page.png | Bin 0 -> 49878 bytes test/trinity/gateways/approvals_test.exs | 8 +- 10 files changed, 474 insertions(+), 17 deletions(-) create mode 100755 scripts/dev_gateways.sh create mode 100644 slices/070-gateway-core/PROOF.md create mode 100644 slices/070-gateway-core/proof/ac2-pairing-transcript.txt create mode 100644 slices/070-gateway-core/proof/ac9-gateways-page.png diff --git a/README.md b/README.md index 776bbf2..7bbe24f 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,14 @@ MCP authorization): 24 slices, each merged with a merge commit and tagged `slice protected server's `401` with "authorize" on the `/mcp` page and keeps the token it obtains. Trinity issues no production authority: the personal issuer refuses to start under an external authority adapter, by construction (`docs/07-security-model.md`). +- **Reachable from elsewhere.** A gateway is a channel Trinity answers from: the adapter carries + text, and everything else (the session, the gate, the receipts) is the same machinery the + desktop uses. A sender Trinity does not know gets a pairing code shown on the `/gateways` page + and nothing else, no session and no model; once paired they get slash commands, the answer + streaming back as the turn runs, and any approval the turn raises rendered into the same + conversation. What a channel may approve is capped below what the desktop may: a `write` at + most by default, so an `exec` or a `destructive` request is decided at the machine, and the + refusal is receipted. The console adapter ships (`mix trinity.console`); the platforms are next. - **Runs on a schedule.** Tasks on the `/tasks` page: a prompt, a persona, the skills to hint, and when (a cron expression, a one-shot time, or a phrase like "every weekday at 9am" the model turns into cron). Each run is a fresh conversation you can open, its result waits on @@ -65,7 +73,7 @@ MCP authorization): 24 slices, each merged with a merge commit and tagged `slice curator that marks old memories stale and archives the untouched ones (never deleting) run on the same queues; `/oban` shows the jobs. -Not there yet: messaging gateways and subagents (M5b), +Not there yet: the messaging platforms themselves and subagents (M5b), the native desktop shell and signed releases (M6), executable skills in a sandbox (M7). `ROADMAP.md` carries the live status of every slice, and the [Milestones](#milestones) section below explains how to read it. @@ -107,7 +115,7 @@ model registry is `config/llm.exs`; keys come from the environment (`.env` is gi pages: `/` conversations, `/s/:id` a conversation and `/s/:id/receipts` its receipts, `/search` full-text search, `/personas` and `/memory` the persona and its memory (with the semantic tab and the embedding model's download), `/skills` the skills, the changes waiting for your decision and -the learn form, `/mcp` the MCP servers you connect to (their health and the tools they +the learn form, `/gateways` the channels you can reach Trinity from, `/mcp` the MCP servers you connect to (their health and the tools they contribute), `/permissions` the rules and pending approvals (a server's question to you, when one of its tools asks for input mid-call, is answered there too), `/tasks` the scheduled tasks and their results, `/oban` the jobs (in development, or when configured), `/settings` the export. `mix trinity.export` and `mix trinity.import` do what `/settings` does from a terminal; diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 123f21c..0b29e83 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -53,7 +53,8 @@ Trinity.Application │ # a dead one dropped), writes the index rows on first read ├── Oban # cron + durable jobs. Slice 050 ├── Trinity.MCP.Supervisor # MCP clients (one per server, lib per 059) + server. Slice 059-062 -├── Trinity.Gateways.Supervisor # adapters (Telegram, Discord…). Slice 070+ +├── Trinity.Gateways.Router # one road from a channel to a session. Slice 070 +├── Trinity.Gateways.Console # the in-process adapter; platforms at 071, 072 ├── Trinity.Subagents.Supervisor (DynamicSupervisor) # Slice 080 ├── Trinity.Sandbox.Supervisor # Luerl workers. Slice 110 ├── Trinity.Desktop # ex_tauri bridge (tray, notifications). Slice 100 @@ -91,7 +92,7 @@ without anything failing. | `Trinity.Skills` | SKILL.md parsing, registry, loader, manager, scanner (as built at 040: parser, sources, registry, index, the three tools; at 041: staging, promotion, manager, scanner, diff, learn, `skill_manage` and `learn`) | Repo, Permissions, **Effects**, **Receipts**, Sandbox (as built at 041: Tools, Memory, Permissions, Receipts and LLM; Effects is not a dependency: the promotion is not a tool call, it writes its own effect receipt; Tools never depends on Skills) | | `Trinity.Scheduler` | Oban workers for agent tasks, delivery (as built at 050: the `tasks` and `task_runs` rows, `next_run_at` computed from a cron expression or a one-shot datetime, `Workers.Tick` (the one Cron plugin entry, every minute, enqueuing `Workers.RunTask` for what is due), `RunTask` (one turn in a fresh `origin: "cron"` session, the summary, the delivery), `Delivery` with `Delivery.Desktop`, `Parse.human/2` (a phrase to cron through the model, checked by Oban's parser); Oban itself on the app's repo, Lite on SQLite and Basic on Postgres, queues `agent_tasks`, `memory`, `maintenance`; the observer as `Trinity.Memory.ObserverWorker` and the curator as `Trinity.Memory.Curator` live in Memory) | Sessions, LLM, **Repo** (Gateways at 070) | | `Trinity.MCP` | Client manager, tool bridge, server (as built at 059: the boundary alone, holding the core's version, its JSON depth and its telemetry event names; at 060: `Servers` and `ServerConfig` (the rows), `Supervisor` and `Boot` (one `Client` per enabled row), `Client` with its `Wire` (the outbound request, the headers, the core's decoder and validator) and two transports (`Transport.Stdio`, a child on a Port; `Transport.HTTP`, one POST per request), `Bridge` (the one tool module every MCP tool runs through) and `Client.Auth` (062's seam, a static token at 060); at 061: `Server` (the module above the core, `@behaviour BeamMCP.Server`, handed to both transports through `:server`; `tools/call` answered through the membrane, everything else the core's), `Server.Catalog` (the exported tools, sorted), `Server.Exports` (the configured names, `:catalog` refused), `Server.Session` (the `origin: "mcp"` session with its own persona), `Server.Envelope` and `Server.Replay` (the sealed `requestState` and the nonce table), `Server.Auth.Local` (the static bearer, 062's seam on the server side), `Server.Plug` (the `POST /mcp` endpoint, mounted in `TrinityWeb.Endpoint` ahead of the parsers) and `Server.Stdio` with `mix trinity.mcp.stdio`; at 062: `Trinity.MCP.Auth`, a boundary of its own under `Trinity.MCP` with `deps: []` (the behaviour `authorize/2` answering a `Principal`, never the token; `Config`, `Token`, `JWKS`, `Discovery`, `Scopes`; the profiles `Local`, `External` and `Embedded` with `Embedded.Keys`; the client role `Client` with `Client.Store`), and `AuthHost` in `Trinity.MCP` (the configuration from the tree's paths and the authority selection, the personal profile's boot, the refusal receipts, the client role's flow for the page); `Server.Auth.Local` is gone, `Server.Plug` authorizes ahead of the transport and answers `401` with a challenge, `Server` checks scope before the gate) | Tools, **Effects**, **Permissions**, Memory (as built at 059: a top-level boundary, like `Trinity.Smoke`, with `deps: [Trinity, BeamMCP.JSON]`; the boundary compiler checks every call into the `beam_mcp` application and this boundary alone lists its modules; at 060 the deps are `[Trinity, BeamMCP.JSON, BeamMCP.Schema]`, Trinity's tools, permissions and receipts reached through `Trinity`'s exports, and `Trinity.Application` and `TrinityWeb` list `Trinity.MCP`; at 061 the core's `Server`, `Catalog`, `ToolSpec` and both transports are listed too, and `Trinity` exports `Tools.Registry` and `Effects.Runner` to it) | -| `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing | Sessions, **Permissions**, PubSub | +| `Trinity.Gateways` | Adapter behaviour, router, allowlists, pairing (as built at 070: `Adapter` (the behaviour: `child_spec/1`, `capabilities/0`, `deliver/2`, `format/2`, `render_approval/2`, plus `name/1` deriving the row's adapter name from the module), `Console` (the in-process adapter the tests and `mix trinity.console` talk to), `Format` (chunking to the channel's limit, re-opening a split code fence, markdown reduced to text where a channel renders none, the default approval rendering), `Identity` and `Identities` (the `gateway_identities` rows, the pairing code and the allowlist), `Router` (one process: admission, then the token bucket, then commands, then the session; it subscribes to `session:` and `approvals:` per conversation and coalesces deltas into an edited message), `Commands` (the slash registry, including `/attach`, `/approve` and `/deny`), `Cap` (the channel trust cap) and `Receipts` (the capped-decision receipt)) | Sessions, **Permissions**, PubSub | | `Trinity.Subagents` | Delegation, result collection | Sessions, Tools | | `Trinity.Sandbox` | Luerl runners, resource limits | none | | `Trinity.Desktop` | ex_tauri bridge | PubSub | diff --git a/docs/05-data-model.md b/docs/05-data-model.md index 1214e51..7fc3790 100644 --- a/docs/05-data-model.md +++ b/docs/05-data-model.md @@ -173,7 +173,7 @@ One row per request, the audit trail this slice owns; slice 024 reads it for dec | prompt | text | the user message of the run's turn | | persona_id | fk personas, nullable | the default persona when unset | | skill_names | {array, string} | hinted to the model in the prompt | -| deliver_to | map | `%{"kind" => "desktop"}` at 050; gateways name their kind at 070 | +| deliver_to | map | `%{"kind" => "desktop"}` at 050; at 070 `%{"kind" => "gateway", "adapter" => "console", "conversation" => "c-1"}` names a channel | | enabled | boolean | the tick enqueues enabled tasks only; a `once` task is disabled once enqueued | | timeout_ms | integer | one run's bound (600 000 by default) | | last_run_at, next_run_at | timestamp | `next_run_at` is computed by `Trinity.Scheduler` from the schedule, not mirrored from Oban: the tick (one Cron plugin entry a minute) enqueues what is due and advances it | @@ -194,6 +194,26 @@ Oban's own `oban_jobs` (and `oban_peers`) carry the jobs; the pruner keeps a wee The curator (slice 050) adds `stale_at` and `archived_at` to `memories` (docs above): stale is still recalled and shown as such; archived leaves recall and stays in the row. +### gateway_identities (Slice 070, as built) +Who outside this machine may talk to Trinity. The key is the pair, because an external id means +nothing outside the platform that issued it. + +| column | type | notes | +|---|---|---| +| adapter | string | the adapter's own name (`Trinity.Gateways.Adapter.name/1`: "console"), half the key | +| external_user_id | string | the platform's id for the person; unique with `adapter` | +| display_name | string, nullable | whatever the platform offered, for the page | +| state | string | "pending" (a code was shown) \| "paired" \| "revoked"; a revoked row is kept, so turning someone away is recorded rather than forgotten | +| code | string, nullable | the six-character pairing code, shown on `/gateways` and nowhere else; cleared once spent | +| code_expires_at | timestamp, nullable | ten minutes; a pending sender who comes back later is shown a new code rather than locked out | +| paired_at, revoked_at | timestamp, nullable | | +| last_conversation | string, nullable | where the pairing was asked from, so the answer goes back there | + +A gateway session is an ordinary session with `origin` = the adapter's name and `origin_ref` +`%{"adapter", "conversation", "external_user_id"}` (010's columns; this slice adds no column to +`sessions`). A scheduled task delivers to one with +`deliver_to: %{"kind" => "gateway", "adapter" => …, "conversation" => …}`. + ### usage_events (Slice 011; the ledger and budgets that read it are Slice 090) One row per completed call, as built at slice 011: diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 5a7399b..5b70545 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -282,17 +282,39 @@ regulated effect. - Never in DB, never in logs, never in prompts. `mix gate` runs a regex secret scan on the diff. - Provider keys are read at call time by the provider module; not held in Session state. -## Gateways - -- Allowlist of external user ids; DM pairing code flow (Slice 070). Unknown senders get a pairing prompt, nothing else. -- Rate limits per external user. +## Gateways (Slice 070, as built) + +- **An unknown sender gets a pairing prompt and nothing else.** No session is created, no model is + called and nothing of the message is read but the code it might be: `Trinity.Gateways.Router` + asks `Identities.admit/3` before anything else happens. The code is six characters from an + alphabet without the pairs a person misreads, lives ten minutes, and is compared in constant + time. It is shown on `/gateways` and nowhere else, which is the whole of the proof: a sender + who can read it is at the owner's desktop. A configured allowlist + (`config :trinity, :gateways, allowlist: [{adapter, id}]`) pairs a known id at first sight. +- **An identity is `(adapter, external_user_id)`**, because an id means nothing outside the + platform that issued it, and a revoked row is kept rather than deleted: turning someone away is + a thing the page records rather than a thing it forgets. +- **Rate limits per external user**: a token bucket in the router's own state, above the session, + so a flood costs a row lookup and nothing more. A throttled sender is told, not dropped. +- **An adapter carries text and nothing else.** It never calls the LLM, never touches a session + and never decides an approval; that rule is what lets a platform be added as one module and a + configuration entry. A command that raises costs its own message: the router holds every + conversation's binding and rescues rather than dying with them. - **Channel trust cap.** The allowlist, pairing and rate limits control *who may talk to the bot*. They do not control *what an approval arriving from that channel may authorise*, and an approval surface is exactly as trustworthy as the account behind it. So each channel carries a cap on the risk tier it can approve, and the cap is applied after the gate's own decision, never instead of it. Default: `:read`, `:network` and `:write` - are approvable from any paired gateway; `:exec` and `:destructive` are **desktop or console only**. The cap is - configurable and the default is the safe one. A capped request is not silently dropped: the requester is told - the decision must be made on the desktop, and the refusal is receipted like any other. + are approvable from any paired gateway; `:exec` and `:destructive` are **desktop only**. The cap is + configurable per adapter (`config :trinity, :gateways, caps: %{"console" => :write}`) and the default is the + safe one. A capped request is not silently dropped: the requester is told the decision must be made on the + desktop, and the refusal is receipted like any other (`Trinity.Gateways.Receipts`, basis `channel_cap`, on the + session's own chain). + + **Amended at 070**: this paragraph read "desktop or console only" when it was written, before there was a + console adapter. There is one now, and it is capped like every other channel rather than exempted: an + in-process adapter is still a channel, and making it the one that may approve a destructive effect would be a + hole shaped exactly like the thing the cap exists for. `Trinity.Gateways.Cap` applies the ceiling to every + adapter, `Console` included, and an unknown tier is refused rather than waved through. ## Effects and receipts (Slice 024, as built) diff --git a/scripts/dev_gateways.sh b/scripts/dev_gateways.sh new file mode 100755 index 0000000..d6e81cd --- /dev/null +++ b/scripts/dev_gateways.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# Slice 070's manual queue (AC2, AC9): the application in the test environment on its own +# database (trinity_screenshots.db, never the suite's), with the console gateway configured and +# the fake provider answering, so `/gateways` can be photographed and the pairing flow read as a +# transcript. It runs the flow through `Trinity.Gateways.Router` itself, which is the same path a +# platform adapter takes; nothing here is a mock of the gateway layer. +# +# Prints the transcript, then PORT= and stays up for the browser. Kill it by the pid +# `pgrep -af beam.smp` prints. Run `mix assets.build` first. +cd "$(dirname "$0")/.." +export MIX_ENV=test +exec systemd-run --user --scope -p MemoryMax=32G --quiet -- mix run --no-start --no-halt -e ' +repo = Application.get_env(:trinity, Trinity.Repo) |> Keyword.delete(:pool) |> Keyword.put(:database, "trinity_screenshots.db") +Application.put_env(:trinity, Trinity.Repo, repo) +rrepo = Application.get_env(:trinity, Trinity.Repo.Receipts) |> Keyword.delete(:pool) |> Keyword.put(:database, "trinity_screenshots_receipts.db") +Application.put_env(:trinity, Trinity.Repo.Receipts, rrepo) +endpoint = Application.get_env(:trinity, TrinityWeb.Endpoint) |> Keyword.put(:check_origin, false) +Application.put_env(:trinity, TrinityWeb.Endpoint, endpoint) +Application.put_env(:trinity, :mcp_boot, false) +Application.put_env(:trinity, :gateways, adapters: [Trinity.Gateways.Console]) +{:ok, _} = Application.ensure_all_started(:ecto_sql) +for r <- [Trinity.Repo, Trinity.Repo.Receipts] do + {:ok, pid} = r.start_link() + Ecto.Migrator.run(r, :up, all: true) + GenServer.stop(pid) +end +{:ok, _} = Application.ensure_all_started(:trinity) +# The transcript is the point of this script, so the query log does not share the terminal. +Logger.configure(level: :warning) + +alias Trinity.Gateways.{Console, Identities, Router} +{:ok, _} = Console.start_link([]) +{:ok, _} = Router.start_link([]) + +# The fake provider answers, so the transcript is a real turn without a key. +Trinity.LLM.Providers.Fake.scripts([ + Enum.flat_map(String.split("Yes: three tasks are due today, and the notes directory changed."), &[{:text_delta, &1 <> " "}, {:sleep, 20}]) ++ + [{:usage, %{input_tokens: 12, output_tokens: 20}}, {:done, :stop}] +]) + +conv = "phone" +user = "+15551234567" +say = fn text -> + before = length(Console.delivered(conv)) + result = Router.inbound(Console, conv, user, text, display_name: "Ayla (phone)") + Process.sleep(1200) + shown = Console.text(conv) |> Enum.drop(Enum.count(Enum.take(Console.delivered(conv), before), &match?({:message, _}, &1))) + IO.puts("\n they say: #{text}") + IO.puts(" result: #{inspect(result)}") + for line <- shown, do: IO.puts(" trinity: " <> String.replace(line, "\n", "\n ")) + :ok +end + +IO.puts("\n=== AC2: an unknown sender is answered with a pairing code and nothing else ===") +say.("are my tasks done?") +identity = Identities.get("console", user) +IO.puts("\n sessions created so far: #{length(Trinity.Sessions.list_sessions())} (a stranger gets no session)") +IO.puts(" the page shows the code: #{identity.code}") + +IO.puts("\n=== the wrong code is refused, and says only that ===") +say.("AAAAAA") + +IO.puts("\n=== the code from /gateways pairs them ===") +say.(identity.code) + +IO.puts("\n=== and the next message is a message ===") +say.("are my tasks done?") +IO.puts("\n sessions now: #{length(Trinity.Sessions.list_sessions())}") + +# A second identity is left pending, so the page has a code to photograph. +Router.inbound(Console, "laptop", "u-waiting", "hello?", display_name: "A new device") +{:ok, {_, port}} = TrinityWeb.Endpoint.server_info(:http) +IO.puts("\nPORT=#{port}") +' diff --git a/slices/070-gateway-core/NOTES.md b/slices/070-gateway-core/NOTES.md index 99e8e23..e0afae2 100644 --- a/slices/070-gateway-core/NOTES.md +++ b/slices/070-gateway-core/NOTES.md @@ -73,3 +73,63 @@ Both are screenshots this machine can take with the cached headless chromium, as Not built here: the real platform adapters (071, 072), voice transcription, media beyond images, and any change to the gate's own decision (the cap is applied after it, never instead of it). + +## Deviations from SLICE.md and the G1 plan (recorded before the commit that carries them) + +1. **The channel trust cap applies to `Console` too, and docs/07 is amended to say so.** That + paragraph read "`:exec` and `:destructive` are desktop or console only", written before there + was a console adapter. There is one now. Exempting it would make the in-process channel the one + channel that may approve a destructive effect, which is a hole shaped exactly like the thing + the cap exists for, and it would leave AC8 with nothing to test against. So `Cap` applies a + ceiling to every adapter, configurable per adapter, `:write` by default; docs/07 now reads + "desktop only" with an amendment note naming this slice. +2. **No `hammer`.** SLICE.md said "Hammer or a simple token bucket". The bucket is in the router's + own state, so the slice adds no dependency and `VERSIONS.md` gains no row. AC6 measures the + behaviour rather than the library. +3. **`/attach` binds through a pure state transition** (`Router.bind_state/4`), not through + `Router.attach/3`. Commands run inside the router process, so a command calling the router's + own API is that process calling itself: the suite found the deadlock the first time `/attach` + ran. `attach/3` stays for callers outside the process. +4. **`Trinity.Gateways` is a boundary under `Trinity`**, and the `/gateways` page reaches it + through `Trinity`'s exports, the way the tasks page reaches `Scheduler`. `TrinityWeb` could not + list it as a dependency directly: the library allows only a sibling, a parent, or an ancestor's + dependency, and the compiler said so. +5. **The adapter's name is derived, not declared** (`Adapter.name/1`: the last segment of the + module, underscored). A declared name a module can contradict is a second source of truth + (CLAUDE.md section 8). +6. **No adapter supervisor of its own.** The slice's tree listed `Trinity.Gateways.Supervisor`; + `Console` and `Router` are ordinary supervised children and a platform adapter will be one too, + so a supervisor whose only job is to hold one child was not written. docs/01's tree is updated + to what exists. + +## Findings + +- **F1.** `Commands` runs inside the router's process, so `/attach` calling `Router.attach/3` + deadlocked ("process attempted to call itself"). Fixed by making the binding a pure state + transition. Found by the AC3 test the first time it ran. +- **F2.** `/attach ` reached `Sessions.get_session/1` with something that is not a UUID, + and Ecto raised `Ecto.Query.CastError` **inside the router**, taking every conversation's + binding down with it. Two fixes, because the second is the one that matters: the prefix is + resolved without a cast, and `dispatch/2` now rescues, so a command that raises costs its own + message and nothing else. A test holds the router's pid across a malformed command. +- **F3.** `Console.text/1` renumbered message references per conversation while `deliver/2` hands + out a counter global to the process, so an edit landed on a different message and a later reply + could overwrite an earlier one. The reference is now recorded beside the message. Found by the + approvals suite, where a cap refusal overwrote the turn's reply. +- **F4.** `Format.plain/1` used `^\s*[-*]\s+` for a list marker; `\s` matches a newline, so the + blank line before a list was eaten and two paragraphs became one. `[ \t]` now. +- **F5.** Two approval tests asserted on "the last thing the channel was shown" while the opening + turn's reply was still in flight: they passed alone and failed when the suite ran whole. The + helper waits the opening turn out and reads everything shown rather than the last message. + +## Follow-ups + +- The real platforms (071, 072). This slice's `Console` is the shape they implement; nothing of + the router should need to change for them, and if one does, that is the finding to record. +- `Router` holds every conversation in one process. Fifty conversations are fine (AC7); a + deployment with thousands would want one process per conversation under a registry, which is a + slice of its own rather than a change to make on a guess. +- The `/gateways` page shows no per-identity message counts or last-seen time; 090's observability + is where that belongs. +- `Console.capabilities/0` carries `images: false`: an adapter that carries images needs a path + for them, which the SLICE's "Out" line already defers. diff --git a/slices/070-gateway-core/PROOF.md b/slices/070-gateway-core/PROOF.md new file mode 100644 index 0000000..f7efafb --- /dev/null +++ b/slices/070-gateway-core/PROOF.md @@ -0,0 +1,237 @@ +# Proof for slice 070: Gateway core (adapter behaviour, routing, PubSub fan-out) + +Agent: Claude Opus 5 (1M context) · Date: 2026-09-22 · Branch: slice/070-gateway-core · Final commit: written at the close, below + +## Summary + +A channel can reach Trinity now: `Trinity.Gateways.Adapter` is the behaviour a platform +implements, `Router` is the one road from a channel to a session, and `Console` is an in-process +adapter that ships, so every criterion here runs without an account, a token or a network. An +unknown sender gets a pairing code and nothing else; a paired one gets slash commands, a streamed +reply, and any approval the turn raises rendered into the same conversation. What a channel may +approve is capped below what the desktop may, and a capped answer is refused in words and +receipted. What was hard: three bugs the suite found rather than review (NOTES, "Findings"), one +of them a self-call deadlock and one a reference mix-up in the console's own log. Deferred: the +real platforms are 071 and 072, as the slice says. + +## Gate + +``` +$ mix gate +prod_check: PASS +3095 mods/funs, found no issues. +... SCAN COMPLETE ... +Result: 564 passed, 18 excluded +trinity.coverage: 050 80.31% vs 061 80.05%: OK +plan_check: PASS +gate exit 0 +``` + +## Tests + +``` +$ mix test --cover +Finished in 109.2 seconds (0.7s async, 108.4s sync) +Result: 564 passed, 18 excluded +| 80.89% | Total | +``` + +This slice's modules: + +``` +| 100.00% | Trinity.Gateways | +| 100.00% | Trinity.Gateways.Adapter | +| 100.00% | Trinity.Gateways.Console | +| 96.77% | Trinity.Gateways.Format | +| 95.59% | TrinityWeb.GatewaysLive | +| 92.86% | Trinity.Gateways.Cap | +| 92.00% | Trinity.Gateways.Identities | +| 90.00% | Trinity.Gateways.Receipts | +| 84.00% | Trinity.Gateways.Commands | +| 70.59% | Trinity.Gateways.Router | +| 40.00% | Trinity.Gateways.Identity | +``` + +`Identity` is a schema: its uncovered lines are the changeset's unexercised validation branches. +`Router`'s uncovered lines are the paths a platform adapter reaches and the console does not: the +no-edit branch of the stream (`Console` can edit) and the `{:error, _}` broadcast. + +The slice's own suites, named: + +``` +$ mix test test/trinity/gateways test/trinity_web/live/gateways_live_test.exs --trace +Result: 39 passed +``` + +## Acceptance criteria evidence + +### AC1 [auto]: Console adapter: inbound from a paired identity creates a session with `origin: "console"` and returns a streamed reply + +``` +* test AC1: a paired identity's message creates a session with the adapter's origin, and the reply streams back (29.6ms) +``` + +`test/trinity/gateways/router_test.exs`. The message is placed, the reply arrives in the channel, +and the session's `origin` is `"console"` with `origin_ref` `%{"adapter", "conversation", +"external_user_id"}`. The user's message is in the session's history, so the channel and the +desktop are looking at one conversation and not two. + +### AC2 [manual + auto]: an unpaired identity receives only a pairing prompt; after the code shown in the UI, the next message is processed + +Automatic half: + +``` +* test AC2 (auto): an unknown sender is shown a code and nothing else happens (1.4ms) +* test an unknown sender is pending with a live code, and nothing else is created (0.3ms) +* test the code pairs the sender once; a wrong code, an expired one and a second use are refused (0.4ms) +* test an expired code is refused, and the next message is given a fresh one (1.0ms) +* test a revoked identity cannot pair and is not admitted (0.5ms) +* test an allowlisted id is paired at first sight, with no code shown (0.3ms) +* test the same external id on two adapters is two identities (0.6ms) +``` + +"Nothing else" is asserted and not asserted loosely: no session exists (`Sessions.list_sessions() +== []`) and the provider was never called (`Fake.calls() == 0`) after an unknown sender writes. + +Manual half, from a running application (`scripts/dev_gateways.sh`, the full transcript in +`proof/ac2-pairing-transcript.txt`): + +``` +=== AC2: an unknown sender is answered with a pairing code and nothing else === + they say: are my tasks done? + result: {:error, :pending} + trinity: Trinity does not know you yet. Open the desktop app, go to Settings → Gateways, and you will + see the code HEFAXD. Send that code here to pair. It lasts 10 minutes. + sessions created so far: 0 (a stranger gets no session) +=== the wrong code is refused, and says only that === + they say: AAAAAA + result: {:error, :pending} +=== the code from /gateways pairs them === + they say: HEFAXD + result: {:ok, :paired} + trinity: Paired. Say anything and Trinity will answer. +=== and the next message is a message === + they say: are my tasks done? + result: {:ok, :placed} + trinity: Yes: three tasks are due today, and the notes directory changed. + sessions now: 1 +``` + +The code as the owner sees it: `proof/ac9-gateways-page.png` (the "Waiting to pair" row). That +screenshot is the other half of this criterion as well as AC9's: the code is shown on that page +and nowhere else, which is what makes reading it the proof of pairing. + +### AC3 [auto]: `/attach` puts a console conversation and a desktop session on one stream + +``` +* test AC3: /attach puts the conversation and the desktop on one session (1058.9ms) +``` + +Both directions are asserted in one test: a message sent through `Sessions.send_user_message/2` +as the LiveView sends it appears in the console's stream, and a message from the console lands in +that same session (`Sessions.list_sessions()` still holds one). + +### AC4 [auto]: an approval raised during a gateway turn is rendered in the console; `/approve` resumes it + +``` +* test AC4: a request raised in a gateway session is rendered there and /approve decides it (56.4ms) +* test /deny from the channel denies it, and an unknown id says so (56.9ms) +* test a raised request in someone else's session is not rendered into this channel (129.1ms) +``` + +The decision goes through `Permissions.decide_request/3`, so the row records +`decided_by: "gateway:console:u-1"`: who answered is the channel and the account, never "the UI". +A request raised in another session is not rendered here, which is the other half of "an approval +belongs to the surface that asked for it". + +### AC5 [auto]: cron delivery to a gateway conversation + +``` +* test AC5: the run's summary reaches the conversation, and the run is marked delivered (1.6ms) +* test a summary longer than the channel's limit arrives in chunks, not truncated (0.9ms) +* test an unknown adapter or a missing conversation is an error, and nothing is marked delivered (19.7ms) +``` + +`Trinity.Scheduler.Delivery.Gateway` is 050's behaviour with a second implementation, resolved +from the task row by `Delivery.for/1`. The row names an adapter, never a module: a module name in +a row the scheduler would call is a way to run any module by writing a row. + +### AC6 [auto]: past N messages a minute an identity is throttled + +``` +* test AC6: past its rate limit an identity is answered, not dropped, and recovers (2.1ms) +``` + +A token bucket in the router's own state, above the session lookup, so a flood costs a row lookup +and nothing more. Each identity has its own bucket, and a throttled sender is told rather than +ignored. No new dependency: `hammer` is not in the tree and this slice does not add one. + +### AC7 [auto]: 50 console conversations complete; `integrity_check` ok + +``` +* test AC7: fifty conversations complete, each in its own session, and the database is intact (210.9ms) +``` + +Fifty paired identities talk at once through one router process. Every message is placed, every +conversation is answered **in its own channel** (`refute text =~ "hello from"` catches a stream +leaking into another conversation), there are fifty distinct sessions, and `PRAGMA +integrity_check` is `ok` (asked only of SQLite, by `Trinity.Repo.__adapter__()`). + +### AC8 [auto]: a `:destructive` approval from a gateway is refused under the default cap, the conversation is told where to decide, and the refusal is receipted + +``` +* test AC8: a destructive request is refused by the cap, receipted, and left for the desktop (64.2ms) +* test the ceiling is per adapter and configurable, and an unknown tier is refused (0.9ms) +``` + +Four things are asserted, and the third is the one that matters: the rendered request already +says the channel cannot answer it; `/approve` is refused in words; **the gate is never asked** +(`%Approval{status: "pending", decided_by: nil}` afterwards, so the request is still there for +the desktop); and the refusal is a decision receipt on the session's own chain with basis +`channel_cap`, naming the adapter, the account and the tier. An unknown tier is refused rather +than waved through. + +### AC9 [manual]: the `/gateways` page + +`proof/ac9-gateways-page.png`, from the running application. It shows the channel with the tier it +may approve (`console … approves up to write`), a live pairing code with allow and revoke, and the +identities list with a paired and a pending row. Automatic half: + +``` +* test the page lists the channel, whether it runs, and the tier it may approve (2.8ms) +* test a waiting identity's code is shown here, and allow pairs it without a code (3.6ms) +* test revoking keeps the row and shows it as revoked (89.8ms) +* test with no identity and no adapter configured the page says so (1.9ms) +``` + +## Manual verification for the reviewer + +Both `[manual]` criteria have an artefact in `proof/` above. To see either again: + +``` +$ mix assets.build && ./scripts/dev_gateways.sh # prints the transcript, then PORT= +``` + +Then open `http://127.0.0.1:/gateways`. The script runs on `trinity_screenshots.db`, never the +suite's. Kill it by the pid `pgrep -af beam.smp` prints, never by a pattern that also matches your +own shell. + +For the channel side by hand rather than scripted: `mix trinity.console` talks to the same router +from a terminal, and pairs the same way. + +## Deviations from SLICE.md + +See NOTES.md, "Deviations". In short: the cap applies to `Console` like every other adapter +(docs/07 said "desktop or console only" before a console adapter existed, and is amended in this +slice); the rate limit is a token bucket rather than Hammer, so no dependency is added; and +`/attach`'s binding is a pure state transition because a command runs inside the router process. + +## Versions touched + +`VERSIONS.md` unchanged: this slice adds no dependency. + +## Git + +``` +$ git log --oneline main..HEAD +``` diff --git a/slices/070-gateway-core/proof/ac2-pairing-transcript.txt b/slices/070-gateway-core/proof/ac2-pairing-transcript.txt new file mode 100644 index 0000000..e4c9810 --- /dev/null +++ b/slices/070-gateway-core/proof/ac2-pairing-transcript.txt @@ -0,0 +1,35 @@ +# AC2, the pairing flow, from scripts/dev_gateways.sh on 2026-09-22 +# The console adapter through Trinity.Gateways.Router: the same path a platform adapter takes. + +=== AC2: an unknown sender is answered with a pairing code and nothing else === + + they say: are my tasks done? + result: {:error, :pending} + trinity: Trinity does not know you yet. Open the desktop app, go to Settings → Gateways, and you will + see the code HEFAXD. Send that code here to pair. It lasts 10 minutes. + + sessions created so far: 0 (a stranger gets no session) + the page shows the code: HEFAXD + +=== the wrong code is refused, and says only that === + + they say: AAAAAA + result: {:error, :pending} + trinity: Trinity does not know you yet. Open the desktop app, go to Settings → Gateways, and you will + see the code HEFAXD. Send that code here to pair. It lasts 10 minutes. + +=== the code from /gateways pairs them === + + they say: HEFAXD + result: {:ok, :paired} + trinity: Paired. Say anything and Trinity will answer. + +=== and the next message is a message === + + they say: are my tasks done? + result: {:ok, :placed} + trinity: Yes: three tasks are due today, and the notes directory changed. + + sessions now: 1 + +PORT=44049 diff --git a/slices/070-gateway-core/proof/ac9-gateways-page.png b/slices/070-gateway-core/proof/ac9-gateways-page.png new file mode 100644 index 0000000000000000000000000000000000000000..dc42f3d67f08a951e7c02249c78bd0d841fe9eba GIT binary patch literal 49878 zcmd?QRal!{_pe)hDYQTZDPEws7I#`GQYadvc!5H2Cpc-LK#LYH?gRpX;u5S-+zAfB z-Q5$`L*MWFuXVJpy$|-$&QY$JC)1ue#~kDSjrmkM9PiexTlnwf->Tobb?^PH zTX(-cxC4~%L3wL$-FkfM-P<=B?#WwdoY$b$Nu0g-TevOvBIjOkI^5S*eaLBnu+UT0 z8)O~VvSD-gVuQR6TB|1(&3<@MVX})D*D65_jG9n=uy2JAz<if*8~u8w1S)pV8*<+kd}&cydqp@0X97kC*;_ z`K^iEE-K?VkT-B#AtaCtKZ z*I!=v|NiTBlBs?F@`3y4laXOZ(;S=at?h&?`c^Sr1DV*OD75GUr@wViKGxF{P4&-0 zppW-*<4UP}0xa25KV0j5}JflsntC zk7;UHR1j|!(RTcu2aCHlKeIrhrq5LsughLt?e6C1R#w)=wd8#39sJ%hKTOueUoBy- zq~zg|P^50%0^hc=sU{#0s7wwn<$<`_oO9Ye`a7B(dWw~$o-sM0nBT9y3PKkjz}j+i zN5lKw9Miw$pPc*MT9la2oQT-c?!ch zZ586j^ZE@Y+U_&P{f0sHI5s;XM1Nn)FOzWPzRJqVHoakN!#qA}Hq+d+-IYj4baqsO z2<__N%ErPmvd&taOs>}g5?MCO*;1ZxSsx8T2PB?wGSEhM-JwA&PwJ(9DH^ietb}X| zI29S1{_VU2TWZ!bGhVAO!F&vQhV5fG9VLz&6Alg&1jfqR#oE{{x4A-f?&;}ddlBB9Tgpt)Z-LP7jTl?sJ?kauZb@~i1DM>#gbIW5oQP(F=ifZcw z-8nU);7?s(yF{BTJ2E*DCm|7@-I6)lwcFx;vvCB$X7||kRO!6xE2N~2t?k__Opdyx&N-EY!u^|WLA?Y7 zh7;|t5~8s)Gb&;d4PK`^$5}5D_j(d|lb};kT#{WocUq}gM~)$mt9?`yJ$Lm#_ z{mNSL7D-c3{l-yNr?vi+bCJH8TfwK@D%@EIn#FCs<5drXLs`?*{(haat{0Vp^TixN z!MhvVQ&rD-S0ZoUy>MHf4(P6s^g5Xy%xcGuH`JGw4lQ&L5Y+wn(OFt2_OB%(WVdSA zyhg1>_=eL6j^@;NU;EDLg7i+~IKuJ4XAZObi+6SzJLV71uMMj|b2eQ6xS*F&BIVTQ zIe~R7CHzvAl$;H?Hq=m8ACkxAuPA>hK#Bav{;`+U!ZV_I)u5|2$59&yr7|MS)>f$^ zj05SP)?2gv0@m#I0nuuc$oyH!vY%B^XOu*X)x*)%9PhO4s{#Kaf}X^S9cRi*`cz*AvK$@BTy3)73Ru z&a!y0%qH&XLK@PFFCzq%)8~{iQ#sxm^lx7@tnoZX?)p|cE#uu7fzQZ~)>AH^X;j(j zwQqmV_?4bCy_kT+5A^*o*~81x_rWm)R1$REqGzan>o5!sA^F=U1oySW=)WQOd`(Ow`8*-}L6 z_hlcA&0y8lo*WxA8fUT4lU1!znYIVf_`FAnh>G67e;=Qc`||~*h|ABm;q`JNiGBAv zd+j2g@bfOV!*D9mlPP<>P@2Vf@7Ml$DmGhJeJLWbadGOWT|R>y0DS#1&*YhJt1 zkCdnipG%pL8J)9Tx3ld@rKA%V?Z~`R_{Q1(Vu4o9FY~;jA`#b(qkhTa9D`Rp3o_8_ zK?(tzPd9tefz&3nlMsWM_gqfG@Rw;IU0rR`u6?FMmwVmm%q5Yl z3$bg8h^vC?z%w$kq%i*ll1A%P!t-`XtmDd2!u8c@LE^>+?lV7@xDO}GP@?-VY6{6d zp*uk>>!TIctzn|mc}bP9XeJf)Q*9%TM(=YT8JdSMU^Dt`L(+ocv9q{ES_GTiM3uQYrcAJg=!`I6_k2f zw$7Jaw#aQ!Y79kF8`S}Zv+tM`TJPZ3ZMpT>yl0S#|2_wi!E9YQYM2vVep5?`@ zfpw7GgBsq{RG#CBj;}rS2}tF^>eLolk4n=*gMMTGuNTk3V}mQ=B+HC0S!Wp;86qjb zfx00C6RWyz!utAL>QeSj=}kA(cTXDmW@H9yz>khxeJ)R2pFAL-7FpWZn9PvI$yL$v zG^~dp^s4OI-d!ARJ8zvi28-k+(UDx7c8wH>R_>;|x6RBHRaaNf4d-V_7fXtdWO)(F zyt&PLH90&|pdGe|650}|c^E*5Pf-buh`I50n$R^fkgA$!8e|<)uDH0X;jm;_F(_L8 z9}0R(b9dDsiBYl)+KRyV41T`h!NK3d;;$^~QnqcgKOEVceY~jLp}HsRb^xhBf}KzM z3)X`BH><@d$RReDfsN+k6U8)*iYkITMQt=7R+e$>>L3~BpHpv(pEy;YMK>}yV^)Q$ zF(1o}6hDHlN6>?V#jxC5ViJ;*jS)7Xv+v3&B2yC+aTW@2IGmkiu*Mlo`lb8YC(dF9 z^x|={xSO(C`rdw|&D4opqp#2D_LShC_Mj*!A|8*B)|#3czQmA_?qL>MG}6t@Zfs{L zN0E|(Aif(>SSaGY_ady$67)|P3h7qd^B#lwadd;YE%N_*FXed3ErQvOI~Rr(|Ddz9b>m}Wg@uL5kom8KeI`Nql8Azu6h>xb7WZDI zKjl=x7<@WlYsUfbra0b%@){>X+ZP4JtvX9MrXs{e-BlZ3Mc|9%dE_xgSVAz4d)~aJ zlnSA$-_&8?h#j;?ptfiKgNNe2S7*@cOJW%+Q)-V-f4&j!*$J$S~XoG!Flc71s@Sz&EZ>ymJNqSuCd zx`QS?`tytmjqYacLZi`#%jd(ktxRi?2820@#^M?5_^Zg-7Y>CfhxB=3Q z_hv^`Tl4c@q?wiE+b%6z6;@W7FFlYncfZQ*d$Bk*Vr6ZA8N{RXzL@NV905&!!WB{b zp6Dy0uLJEO^n73AQi>J@V|s*LFKnQ@zPSIMA6AF1icm?a?x z@SL^J!TcIZwo2Q!a>7+qOXDdb&Zlp}Qx3$CDn*i;Z#%=2FfOplV)SKm`k+6J*NLT) zWUwQ4vZ3l%jR&OytmnbQ?vtx%bxzlR9EB)@IU5XWqUb0uEby3IM11H%tRej~>nJdz z^3S=I6|HNMQlHCH9F+$Gi=OSXx=8(y#v55E0RDgoKosyPqVGL9xeUCsay} z-G76o#FoctN*y7g{3_gPH_oT`8aE|5x!$Qp zjK}FD%d`!!;W-j*auz?aA^A1lHSTKz8OeIP^o50m8mzG~@{Ug){6jz~|2;kWpR#6f za4Uv6YplwCmRj8NxtvzP2v|AIcO`+0Cm~cKm3NwzBy|Cnq+S}m-ke}GmxG=%Bt_iC{q_{uV#t*xzPWZ)yRr&Jgb4CXK={_l31rs`5% z1AICkIO-HMvu#rt-S5Z6B=$3Br4s(97pHtsk zF-?i=Zy`U>G_?!<&}GZE(3J??pLJLoecHyopqjF$r?Eu421tS3eGmWR-Q1Zf-`Vr2 zi;4_lBPcJeko(n{V>FYb3=Ka61K?i7d^WwHoaKsURDMpH2ZajTev?}olm25gG$te4 zL;cF+p3IxgQADG|d~?-ok@v|2ICQ@Su+47fW`kPzbVgGDWdYWd!r}JDtPWEy^6bA|_xrU1+zfOKR&zvCuWEa`VX;4uv9PG9d`vRj6y%fCzxPYD#z09&o;FyE=Npc{3A3W}pDO$1bFLR}xnkM4;D@Zj>t+pqgvaoTf817f(EKJzlMYUgJcHJ&`eb_lU4FP}S!`)zw z)_6_>9t@AA?qtMK`;8%+^DQVG78VlWX~}U&Vx&20biJn+&VeLlzB=REFDnBVH)_G1 zSHs%~bhCHIvUkQ4M%=%)91&V@Qy6F}Y2vpR{r37(Y1lu>2EEpsM$+*MhS#gb zaP)$oTIo=HbKIc}8p#{c7Imqfisz=t=O44Nh1)!b)xs0uYXn33ZcfO=@1t&OBQ@7~ zj!rgC@0E2tE_1Hux4X@5YP;p&k3gh=kdTX0*u<~OA3ZguBm9(lUO|w)zO9{@m^^DZ z^RN7j@!ayaAm>Yasuz{sq=FFG7GA%o*)Du%x~413nLbN+t9yG-Efdpu=byionp;^P zBRnu+h8L%cm62fQoqO%Mm2h}sGoL6@)Wc;q4=|Q+b!+Qy4lcUeRCP{GYehW5jI~%3 z^sU{lnR>WL&6tZrjzn^|_BN+ssib3+mr$D5+rGbbr{50m>m#Cg?n?ke)P0@nTZ0Sh z?@+BcD=6^C#)^n0S;22_Q`tispM8 zD=M~jy-vwng(;^t;2Xi#+H6NEloOMnUo9Em+J8)C)F~;2VbkCn3R-%-^W&3_R-py? zD60zM<^K)a*z&%s-K*TBZazwrlyvhj1cRJsV7m%Epn2`~LWpkp$t0RzU7xvQkAtgNE!)gQd0@0i{ZRdaZ5n**;Q zet>gqZ0zvw9sd7vPYop{*RaakC`+fhYsFFbW&OfI#+Apce9t)MDh+D~&_^lPs zQnAmdq3j<&z<645WB=vU<&GUo<+xJU<1tIBPe8e{=3r9z2a4uXJP&b+p(J86)++NZ z{%?PYayml#2P*h1^$YDh?HB+0))~6nAW{}0o&-K#4hH&&e^<1)-wRmmUMp%bdZF#R z-N}<`uk+BugZ-gT;1!ntkV}dOPP%us*Jz?0#GBRFN_omkotJgLe4vZ`=8-^lPTuw( zW@`VU!}7j&+TK`8U7mDOk{A;!%WB)=rDS4T^^>J<%~5WF>vHVsORKg9kw0T}K2mD0 ziHbAw467_KJl(TMe;-%IVy07D^0GnbrIUGj@wnd0r!^0mL)BNhb2j4`vdM9rWw05M zwAiO5A0&T*nf2mQN^Nuk^_@+ze9G<`pMKE9{zmnSu-WPi)h7Adw-R3jwzUP~ti{Vf zQ7K}g94xPtg5#gwET-2}pI^Y1_glYwnYkcnt<8=}HgCNn=EjxQB#bL1V6B|_p2m9b zt1tHU%#Pqc7h#77hlfkOiP3apj_0XgqNUqvk{C(8)nTOSTBWu_Yb=TA2eYGF{Oavl z^q@(UD&p3Tl~^hqeYJtim_I)#+=kY{+snKk{-j12YO6wxYf_^ZqfPGG)kUkkez?fA z!uwX}rcVP}4$sTEi&U2whTT-}6cP|9 zB&+>1C9afHPb*!WSRMtc+-P~O(D|Sjx+IT?87#kF+B4kajrkZku4qAWFJF@OusHD&<^HORyp+qgi z*Q5o+FX%uvws18Z`2D`v?KE(SO>D5=Q@6HfsuMBbr(|>tY99_#f9c5*1%KtMuk2f5 zU9VND`R(lOE)m&XMn6)s{(b-Z<|p@m?bGC=d%_&yes~P`iSH<+oO$30uqAnb<6l(| z1PO(1J|Uu@5q*KQy~ss$Qw4&v<2cZq`1)YMO? zDc9(qo_+n3^Q}HBYk~LSlhvD%y-vDWtNBZMIbFXVKY6AUj@SQ#VP$%!*I*~=wPXvG z*}7U8i5mBBV!0ySXN%4%GolC(%dv61{I$?P^3UFYW7#>qMA_8HGg;Xv)u)xrECK^) zQ9f37gT$XfNuxI{q;oX!3L{=KD81_&lH4k8Y-Qt^KmL(k^5u)EKa<=a4uk00m-2sX zOYccb1?|hE(!D{^&#p9g3V_#XemkZ;Zseo&E*zI;KBC;B5gNU|K)u#Q=)zRistE}Q zPM+ZGFH(*v?;5llS$&+Mw^*uCee(440jRl)1KA7&}(%e z>SQi|YM**p{ZqvQzNg~uytsH}7iLMMlW<>t@$WQJe0i_DIl#h88WbeM@vgT=lL+lT zB%>FTRwK4khUm(jsnPny3F-6G$Uj)2=h>jkkz$M`7C1gqI*jKq_Xs>{%d`xMInTv$ z;S**vS>z?UP+<)y{n?^%u7G0YM(|i`4%abExCimwG*(V5upL16-y_cfNq$p!>%)JQ z!~gqf`Tyuh{eSz=Gz}#|dun$@Sy@$C73|vC#`bR*475x22HJ@##j*pB_(`+ zt)LgH5)Lj-xetOycqWg!6%|J`V=KllZVrpWI|khXh8-*WlpX}(M5{6Ju{;(Af5(bw zZEI_5t+*?5%s|e6gXM2okA-uke_ox8?XIY(c%&PG@Q?odrXc;-OmBVk%49|G0ngtF zBD|HNtY=`by|JRq@%oQ2BCsW0QM!EQkB06!-0 z+YSP;+~0o{y>;$Q?apeW^(P5rwR3E%lKI>1$6j8XK*%_zaFf}|kPrJ_nb2K?I z#cN><)Iw?7Ue5m)3c1e#29XN#NcU%bN8MsgW!#F$m00q=;$!_uf=i5k~8J*rlGBMmeDOaukfaf z%FNOW1rJ*?DJtM!7UKN;{D9K=NcXuPr3Jo#cWTN&LPd_-cU7s*$}$5bN6X#{yuTBw zb+3QvGg*;b#}NA9ihk_+gdKms8pr;6Ku(D2+?KWZRVjech%2*NO>ai_QReZ|>xX5F zYtBNpKYjPNe$n5QejqI0ZVD~p_L+H5Ql;|IW~KgOTYZY8Z{Fc0VNHP7#7wVGJSC<@ z>d}t!Sx-W7!^K}E#^UbYdxG*86sfj~jh5*vYrhM?R`7yxbb3JH{KTkL!NlFUR#KxY zS9I+{cl?C7>(<#T9GrF$@s^erUS8f<1qN%1T$MDE$B((~QziC9C?o&N0-l$Zl;A$Z z-$o-zUG2|jud|0JH|8FmarJo4)TB7u+HRlDVjNDUbvDQz&t0$md^IfRd$gt^iRl4` zlHLorK)DV94H+ea^S(25ZY|Dqx-zo*!+uA1s1`l{k&_9+x$yMbFITAHqi3MCUe`>noi`u=-F%)6@GmmE{$C+kbPJI3@gzuhH!32e^n6qdDPMR&TmB&V7zQ*))tW^7Y1>5_S(e|Ca0uaV~PXh zBB+s&?aAREEJO&*$yA&#<_}jL_a^#u#?8$Qy6~N8272v(aBwhY1huVBo2Aabf!wdB zfaSbB7wkup^D^*TkLUiGSnfzW>z5M6XDxL_r$q1CWEkD|E9OJ0^6#bI{!M#ZF!#wo zx{l&k5wlMwse!l{Q|+SaSHz|}hQ42Xb=Zzlyl3;i<^U(+Soa~pPhu{T(pA$jV!jh8 zvm&tkG>-F~>EizG6h_4lZK$tYeOmn70rXGe3sAbS+v&G6*=3HcMRA4*Sxtudk03qh@8O>DyohT}Q zm$K&lC?8}TUyOCA&AohGrDI5`B!fudKoP;ALV5M-EnDvT*KhjnPEH9=I-k9-@@9Z{E6b1t?&&iouaKO4m~k3F+YF*No0AW=eCt> z+e&k@6v`sa_u$=@-~CuY->#Eh!PyTfr5DYuAppF8uLQL^0;ODCq59iZlD~!fq6dFR zN;I5eFWVxisHluQ%%~CTW&J$^npz~Jq{Sch`HyJ;)p_2xMYuBAL~}o}TqCUZ_hVr? z6UbPRK?O0frshZuPXZG83sB5YAdpVBh6aauKVOd-_m1#=Mm!qoXuii8@<) zpRfPUI5_CFLEfSwqgnp!|ERr^ZQ92Sk&*-F4 zJd!a2<15*Z!M=}VgmiVRY`jZD&t=F-=p9Nw=|Ifysu8sRKKRERHJH-fXNb8d#|rQU z5p1)Ensk?Nv0<=WVVO~``%d`V{i1aReiP1J4~vT?3pk7^I~}U_(X0&;j=bj!7Dis` zT32^tE&K63zV6N%BIn^~_gM%;u9$sFuUqIOn%Z)8e9n5^QjOx`RhFNhAED&cHxbz| zN`3(c_WQ!aeq0US=iOY_(&G7!lw@QHF)^P?U<18j`Yi?DH&x5AT)Ejw;Pu4a4`P0w*ArZ3>k}E+( z?0q!YUUE3Ae7VmQAZ5HI{Hfyy7IO*8e)9$O=le4j+ft_yc;IMrMg3-1ESpM-2)fv) z@z<|`){xnpR1IE0FnY<)5mqaCegwLRkK3ylGt7CxBx&TkH;|DJf(;y`33)V&HD|1C zPJq+SJ+^g0O|ts>`pid}dxhz9!^>>(Q^-n~duLx?QEuf*7NO*OC8c`EF?o(B^A*y2 zxeKHsK2oJZ18+OwY`B3LlVWx5>4-V6f5@etnt5I=#Po>e1Uj*bXl4YPJdLzZ7YukQ zlkn~xS*c&0QfEO~F!ODCg7gnv68xcA`9Pehu9Vs3u0Rc3DJlS(i%bJ?3G;l5AU*qoisS_1Lw zUDkn|8Zwy&!zexWzRrm$n0*?6ft#92P`TphD*Ln{b)Svz%}MQm-0gW}0ZC%+oZlS# zi1cpyj15@r2LV8twm0^0mFp3u&;Va~p8N3Eq&0`k$Kp*a|gjSa;uft zz7aF}mug?oZ8n94>N1tD`ZjczxC$N@nbhJ>G_3E{^NHj>*L= z=4xFV6XW;xS_nZ4YfGKct_rza1|G^fF8Lj8fu+v~vigJFYcZjIUn%XI;VYwATqGSHR zU7r(0{OKj<$qEJNs?@z@uMuW+sZ9p2F%$~RduMlA zrr-KLle+4;a1OW#;$)v)(aI`V80SA%6utQLKI^xtk}c*GPlNyDDzt>buEoZ7OQ@oE zstwrjqQy7c)!Jka0VjgmV<{34$TwJBfdrqm z!Fr@DqUi85Qs0+P*rWCH7tE|d-5GMX5%#tJxJE#zYy@^6YyMV9#^nM$`x7(J@5{Kw1c($rc+R zM}gC_oCcbm`lJjDs(n{50>mMe)?+(|{f+6VcSvvNcorEVCkN{n1Mw;H34N4~B5%Bz zq@ahPs-F9AQ}G2L*I7n6ZZ3R0T6y{`pM^?E>t3?(I1o<#2=&B0b#e{OCL=IU^gG!>evu9Azj9xc?VzYq5l}@jb>9RQ z11mS%$O5l4Cs>|~>lC*nWC`9{;DmR)`+D)Ahi)v79DG2b4t+O^&Oe4l;oYaVpRsm) z9&zvUO{wwYI*1kScn4g#mDnoxb@oJYse~Las(JqbEt_`@-@fhlc~6-3VA*M~u;%v# z_zRKngrH$eu2#MV5fSd&r(C}o@yF!o)D_Imb(~y(A)A=!81<=>=umjAPwi+&#AZYF_NcyA@iUPu%m=4F^>hxx{g)Rl(wm8mg9AG4wC@5=M!`;IaJ2xbS}>I~W!@ai#><_{ z;o;%6F(V+^)fM7o7+srNUOvp1E2Z{2vcHc?r(B+hB3X3o!w>1jEy*9qwG=nzy|c#a z%jvV8{@q3}c9ACXHa4r+00b>@SqI)_wf@f0I*NHjzTjlLOVjnvua!&8DFOvkIy^i? zL0v^K(E@<4D(rQ#_5B%Bo}RCUgyq6Z)3{t(jcatRn-@J}VC@(-O8GmLkK6?p(F+f+ z(^UtX28`CVD-kg<5VY8e9UL8B%~G=0es<9g<7^a`b{)<9De0B%yohh-v>)9oe~vdqaQyjQ}8tXsa3*a zH^Z?Tp=HMOr!7Y@ECRR);tlJz(fo;#|%)-yRy;m#5Sa;|ywNIq|geWC1;^zk=M{S zB1B=)Xu1IK_}rVfy;4?`JQl|8NZpVEcwu>26&NVLwbztoPRma~R?ZDTmcE~z#{U)q z96;ppQhBvPO8C6ohnuq3*KB?Qv9a#l)vY&80)&NVwS4j6^PAtqnkI4eT_*ABBH+Y; ztbq?CkH;jIuU5uZG2L*Y*KAq2=_v>U4-Yc`xFA44(MLK>67)j)i`ryblHw?d-#J#3;%J8HlBz)~@0m5v673fyx>I1=Jhwt8o9di8#D_Tc8^NJK3h+m`cwH| zei3j(RQh564*b?M=>JPOuGx6tMC^3BECuOe%wf4TUgGQ9@Mx#6+)@QceE%L2HuLQ} zknY0e`nESk52nKA;6@r46jX-fHTkm#<TNlt{O>P%`ODQY3v<#lm8H z7_v&ymk_PQFfH9XZKS)vH<+lK@y1E;{fM>I$k94l^ySO!jD36S_;UA;HB8`l7F2-t zmRdFIxe1uad`nBE@QEB}QXJ~|ikE^_1eKIut4V}JL?i$=8j8J~T?wrNvZvs1s}1-4 zR#K=zRnggj8Dz}Y5P7mSQ8qkS?^Pw@qx+>^Tzko}YPIb9GsYqX9iOYSl9f<>So69m zOqpupZlPC|9VG#*wzjtOmA$oIg$3Y4*LxltHu~1@n_Amhy7eqH#Bs%w=4L_{H2N*_ADn;@$!`BV^w~iW4}gyu2xud znKM08PL z8LG_V7|^O{ZN$!ERC$U5_gD|6)g{ql(M1<6N8{7;h$WKKKm>Y?AuMF5!9}x{6K|c2 z2=7!_99OP=xvzU0=+{$9%5uj=Mm#6k2pJVpdkI!;))82$vv`q2fr_umzQsVECgch z`@#9@5AKGr4d7pi=tS8~{T2!-nI>QXWt3P!c|ap+;?&}HDp51IxX-LaUO&EmaPb3FZfg_S2&075Vm2%D|f zJN^F5|NIUqz{jqAu=f!}29ZwA&RJZ*76TP%L*=?vjsb=x35;cuFE&4-)jqkQVlMp! zDJdxv7zXd^B8>Z!RIHd{k8mqC9Zkl9oL9m zp*X|?2PWZ@aDI3_MQGdE#^B=MkY}V8AuiD1wmVa<5$xv|LdyBiz0mOMJ$e++@azK$ zUAL@0gW9i>r^zYxRPBLfK)^Qnv%bC+1bc7rl*ax_UBGTKCN0h0Q5)WPO+GT~WWlMT zl=#-xrIrYDzN%Q9Urg4Xjc}B5;5PU?Y%yG3+Ln4;Wi>fx8NKLWV@WJ=+1PJwZQWY{ z7!=~eW+N}m$c3J73dfIsjyI?JKQxmXv4cKtTn6-OS-YO zB^Fz`-IuauV_S8#dy2U{g#w|TeZ6?&Im7t)xY+qpbUEhvq2y}c%#eI(_3nr13Z51#}~6Ai0@ED1#(QAt#ZPSd*zEfzOhTDqr$^33P}W5}fsw4%5}v9t(I#`pL{} zFtx_KmwdzC+8XIFFBK`Dt-)$Ag)BPrlt~%PlR2Kbq~UG}gQI-S8%rhO?Y@;|8Y*31UmrmwDw_}1?aNV&UOS_U zluu3O>#VzP{$zP}U0;e#k%RX_cI~z7^3Omxzt!S>4k@PWR}o@YtwS%Z%GE^K>FAo> zgAu!cg|2bfr{hg_TKkFI>gL)E9VjwDl7bueX0NZtgvdRB^jmOW^7*c|(conLMHicd zgv4;B)u)n8=%NdW2XOSpF>vtC)Jr2uM52e!M}g3n_&(kuZPJFf{+y7A9^vzW>%mtM z>`J=k@&W|OeEV@YmS=acm6DZ%f>Nun^TXL(h4kWd?5Weyni}dQ@$zymUUsDohWOQW z+YM49iy7{myO1n_V zdBp~ZtxZ-pr%)tS8*=TT6USpaTasmA&qj-DF9MPZ8t+GCwTBmd*snOZs6vJ$K$A$? zvjekKnXjfFg9c?x+%kS;FceKsB;{_fCaXwM zintq))SQ-~NZ`Uv{NN0@%LjV_!&-p*QePd%bROiYBq>t?PGQc#H_mn-1@~dCMjN?I z0v(u%$@FxhY*m(wJm1B|3#fuct!ddYf8veLn!{tFqkFOMaeOvR>xjT_dy#;nsQy-t zWw)yfF0zcuN$=?RCIXfS5l=aG8-?%~7H0a(19|%6zcM7Re&v=IhH~B;qO5OS7@74M zgW1!FINkpzoL_n5>aJjhqDeWOOCArQQ_rB;y1q{8{%u^5h`ygsWI0+d)(M=Cmj5}s z4Ep-@)2C0W=eYoZ_d@X`3HGf;Wk_YD`_sFkWPE}@d^r5su-|7OqLzR!{!rsukJs&J z{8g==GL?%%{{*tXT0Ne^xA2?5U^J7mdT*MDD;#@jLcKK2mdjvMnyJAGk1#ou2($m1 z8x@x4G(9cyh+6C$c8y*$|Mi>{#n8p^<|;)Isjr1lhVFDM`6QIXPPiuuxVi~2G8v&B z)$8HhY=RIy{*;xpg#Gs%1(?Hr$%$=XJLm#zEJt*vl|6iYbrWpGAXtPYMGwpj{|JP4 zEXW#OKOCWP7I7?wP&=gcS)mt|SqbE0^J|{xveWn8?Z^S;&tS zE`n!F&JZQ)L2k5m z^!wP?2+a_r1Loa(MOQs=8{p9HPpuPX@Fl{lfBHcd3A#6^_SV@|ydLMQFz4&7D#J6R zy`vhjZR42LG$_VY`Q?4#W1e`b;a)GUd97UhYqrvA57KdbH{4U>2 zt6It(fs2tjkwY8(Dg3Ul?Y#GQ4yxP9#~C(v`P~{X1rN4ce7*otxmxNl_?wE3<@$iJ z^g6wP^4=+pE%nj>^N6M0@ci7X zYMqElk#1EzFU8A|KvPplVWH=LSwQ~JB+y{OT=(@A81QUd_U&h9d~(Z?_+|Mhq9R0o zZ|8&?peCXv76tn(0mXffVFeNVDMTvAV9bmInxLW?y7AZ6|tj@f3C@z=|tYGx_oB4l)uLo_h(DOY?(g^PRbiZQXssF6pgZa4fMVV4Iou z;`spzx6c7NRn{kbY4vm+6{P%OVgfp<6SY$Ktu;%8*Cr@T#ahGztv{E`nvr2pErC); zk0wMGX*t+!HI@gACz!2n(CjtpHY6AMh>0JsZzOIkcSrXIml&IefI-D=?>C1Wa0SWw z^@);Ny8}^rCJ%HZ##Db*{Rd>-1I0-KvFwc0vfLQ+<) z32+@0UOqL)js4gql*G$exE}L*tO>Zplz0K$X*;aGxF(a_nX2lSTQK#`DaOkBDaQBE zEOBSB%@$GVE77bg07zAo7r2W-I`d8UQF(Gq0PnG|_mYEy!;f{cP|rlHpE9N7&iX{O zx_6K2wrim7)#yfdq(Trr)#RSZ@W{Za$&+PfW~sS-clKH`+!n zFCTA?RqewI8kfgr@9-x+l`v9;P6Cd9qP|Tn`2mm-(`H_P30pz7;EQ`u7G2~}2L1HE(D){9;v8!6iXK&UBX=7M?q z8IL9c?woA+^LJx)o9yrC+0?J|W{}!kKtP7l2ku(GpSab<%PU=(z`2bgY2GP%N|Le3^d@%Du?IF6trChwIkv68P(ukcQSEt-A4Nq3ERXpEJ2!X%Fadag3Bi^AzYn@?zt1+u9#)lzG7zND@^Myn+Xd{V3-=$^U*x@ISe0M9?yVRo z2#BPlf^>I-($d{9k#3|L3=k!ydm`N+-6h@K-Q68?vIqEop0(b!_OXt4AN#}m;r*&i znDd_ZJ;u1M^E`i-NNCIDM4|o~=w+EUhD(r<$%6ifWsQ`G@&`O$jc3DJo?CewpjH$` z4vUSAgw|ONroJzzJ*DR%Afa1=8E$<_Dn#gWlsv+I8btzrNjis}e&yvzg zw9%qP*Cj${zkvihL3=N>L9%+&JiXB7EK-8wL6-rL;%m}`Gx#N>W{EY zn5vhmnaNEWw`p%=)3d3g>~Q6h2r~(Z%*J<9Pg~Nk%gvUi#5md6%}T!|7qo&fP$a|) znb-ArT|YQ5IN^p}I|forKH?q{TkjuJ%(;4rf}fncI>-k7bF_Bh4wtWl4`#KXq`+~c zeLK0`!Y=AgXPoB7)IV`68*EzJkd`}JXjR&d&=k}hE~g&GPHJ?T#Eh0)kX`S2)eBs0 zv)I_!tgINe;?PAid^kG$Pmk3$$DI4u*l${}9CXthfe#j&8;OPxD6h57FldChB#@b! zh-?y%P{=}S*jQN3T1!)bW9Gj~J-yZ0d$}(*_x`L3Z81c&Na&Uq^paD*_yJ91^AF-Z z7D+A8H0CEK8zChzy19bf=v|sW$HA}ao)drCJs9Ip?f7onUE7dM*+@kNd#HZuat#L( zPl(Tx7@3_=d^Fm9guNw3iP;NHNPJ|LAByVMpO>>}J(a-=a$Mz_o6;CQpRxWzWq4f`IM7qW+{k$9{2+@GeJOg!6} zvnG0@W&n4MX>}_Ly0m62MzF_==KSy{>ZbAtB7NV)v=s+U&He1D+I@eqle1av_@Cz! z9@jhGSI+sB<-7CUCx=}((n# z-g)(*X~*3x_(sAEAKd#iwYa@a+tt-n&{Ud^PAVox-Q+xmZ78C_)ml++hsfwFb=#xA zHS8MvS z6B%wB`=74vzw7;)>@S8x6lFX+KtRY{5qqQHAOv;3J^*XY?@0NKeforIPS$;ly`JG( zH;&Oi;9OksBsQPp_HCIwtIWjlU-Y0NVlk)Xz zLyIBM977ww9-*%8%+~t8^sHA;8NIG^$T7vnAeX}B{CLlyeMQU*b}|M!q;hqC1_llQ z@nOzuZX8Yc@+uf?opa!>ZhGcal%b#<8ZJdD(Q~eL$2S4k<0s~r&npDP`wA5_2Ej_KV7+AnO?sy2uss}-N+fi40Zc@ zfL4@}GIUDFQ)W3O>ap23;7+)u6!bM^iD$1yQCs=Fa_y=w8!*cKdUs-O(Sf)Z6%{p9 zuFD_W0zFanzEe(MU%~OY(qWy^bg&t0fSEfqcl``15FX#&+3^wNh{%qfJdUEr+9;_q zhQ`>ecE{X!o?xK>*OjWfyZh9-ql^Vi-fsw>UD=cntbPiWxGB&XJXtQ@3U; zT?_biIq(sF1nF$T$KB5{19ACzhn^et*H4x2F-Bq>Ecy!ojURMEXJ&kun3%kFE+uUg z-hB{tJWVVSPtH#Ud)39oh2!3+Wk=|FXJ@DB5R^#3N^3G$K;p5a=j8=OtaXA9D?_~d z(GCYID&4{#A7tPj%k;jqqot!W&$I?M>(Pq!0gerLfJrdwX|l&jd1AG+xeWGR_ZIIwcaS zt9c?^HWtY^lJI*3X|WSIJ^#3~QeQ9s2qPjQ!d&LPTu%X-kRwO3w@BqTO5#aAjS z9dLP_pTh>kG-N(E7|zvs)OprnE<3xqDQ0_zYTOp?FG24dGjM5s{_O_3V_d#gr9!TP z!^ZSV=LpTonhBEarcw}B46XHL$%gc3v?;6q&@9aF_OWfK^#S+K$mp=y@Mfat5j*jqo zKF+#s#Do>t_hii)i$!+?e)1T6M|(s8yqUgYI1*#PNV)&k%4+6OW_C$=aZ2-WG*j^P zT0KOY(+hskMr`NkILhE|xzlr!mzTzsuYKk3qGbcV&5qY$RA-0%LTEeFF_r{*fS`eb z&;C0-8b?P=S-CW!Hlc+pwldAuNwMY#1uvF7HUK$UDFzl)0tc2r~IK*$Rm zktq6@v(%Cw8)Oz_WMm)%KAfd!72{^P16&Lg((&V!b{SwdViF=Z7|sky;BhjNW@4Bo z@wa2mQslx`&n+}6svrRoOZIOPy92d69#?n4L=ALiF1p?SjaP$_zaIb-g1&#BI5;52 z6O)vMZ~)C`Ovf!^k$^|D+6#6ycj8~=eqq~~^##BIs+uJYuy3awGEJc`J<13-;zH%5*RK$!_-W$&+R2d68 zP3++U^3)Lk>VXUzfYda=0tXDpnG}z-Ly94(xtKYI#2(iNUQu2r$HQ4A_rO}K7_XFj zerXOvFfeRsp-UcNjkE(y0|;2OD~%J=POy+>_@%{pop$3}xJId_7df%lObTs1&p!mV zs1Fz1T@@~_!Tx?ZpX5(PI!ZaEyHRiq|iRyd#+tguztOO*$ECM*r8cwrfI^G4Z1ND8)YE&{+?$w zbNlz_0mIMK)V=-!f8|1K`J%q<;s_sK_R!UpKG5+E-(-2=K?TW8e+NH_Q-MuVqaI$! z7MF|sAw#`!e#{;PE50-%r@if`c5O^XE+rD(Gi(SmpXbPAcODHXK60@J>R6Wm>-f~n z3=ik<9J2h~8EF`_l+sKiiV#ZczB0Usv88smkrSRy78ywTD*gFKgV3Ah0WW{dA_QsD zm5uNT(>Oc-VZS`nm$rBz6o*G>``ACGUCLR{MqDbyCX9{FvT1Y9R4Uk8*}`1}pPP9yX!m#`*O$t4XxMsh}AC=_o%QW_54dr9z;a8()| z1rm87nw}KZKQ)v9&!G#a!bg*^@9b{*5aS;gn@7!aK>YbfH}%HymR?4#{_Rz}7x^k| zXpvPSMvJ(WF=>1#x^uzr`nu*>(=^$AJt2DEu8im6b4lS{%G|9d&w}8r4nwG>aCDd5 z-EK&oUoh^L3!n9$DB*&UxtlUO)8D@8hoYEOBwOu0SBn+Ug&fp=v5D*$=w+dIX#d62 zt}z3P8M>ihyI1C0lglDQWUEdUHhkBl{NnJHGg_Fo9bzhvmn@`|m3|sB+>i2scaJN} z?U^0#Pa?kaOEq_!kH>?@d5Fhk78)<-=9dNQ`_UBGcNl4(!FGBlOUO+J?CazD&<lj&CT{@(0@V97=A>l3_(kCL~>hE}&v@zM55{(2fblRLaY8b5!o{G*X% z=m~-oI?M$*Vq;^XbG0WtIrY(d$tJ7ZxP~rby4nDPP)AO&!O;p+M_l)4kar_Ldts$EJ( z3zN^>cE0KJ2c{g`6OP|MuD{^B`FvugBl%T79tUr2Gvqjz3swms!sma@=sX&lC*ctT zPij^2XVkR{c>PD-Dlqtor}OXSg~|<)T${=3l@CoQb_6d%qcfI}9wcMGB#BSXU5@Vm z$t5|6r6Hjb=&#eiFWFz>$EpE+rO`$U+jiDTVp?b|e`&JvvMaJ##OJZVHZcLVIi=qk zAugrWGS{!}2;z zS|9?cD=pb&O2a`xp)D=asTyueyPz7mv~a`p)RpxVf~1K!5hhm8A6e%rth+g1UAb+Djb_=7rlnejVPz^ka*0zIJj{CIfwhmHkC321&p=80 zb7i?5DWvW8bnX0c!LfAom&$Vc$m?B%&*j3jgOB~FT045W+d~fyg=%YB8b9m3WO-xj zXQ1G-koVxy*mhMc2*V95AVw?u<9sQjn3$$9-`ke#Dg(MOaA&t$StVKsXVKc6cl8)x zaTvq4SvBoy-_qZbD>4%^z-pb{^QDGNIyo+LqE;WH||QsCC21Yh8r8LZCMW-CKAvdEWcd*>h2NH z7$r>iBN1y}8a+rOOO7&JM8mN!#&4M^AB3fG`H#;3@Su`ZIKA{TahQzDp*ju@3PwIT zO21YKDMY%nUcEg6PBSd-G+Hq)7VJJZ+i^}mo8=fTB*LL(;}zUpHDnN|T~Cw^S=}H$ zSaW-PDM_i@FR?@sj8Dw_L2rGrTXgb#O#cCWqSM-0Q5Kd$)lo4EfPA&) z>d#)LhSAm{2wwAD2o~;*zx{!zxN5=tP-3_7Xeqf>q8TLk{FB|CzhQOtTF>*{<4tPr zlcimDJNM}eKCXtVh5lhJY7lZqg9_bZg+C!81K&?_i1AD4@tC54v5s|Hj=@WHKYWj) zqCgL-;|_LSBL<}R-R%);#2qq7j@G|*6PV2t7Nkvgmli!cdMCppEp9VfB~MA~=Dk9m56=qg#l(gJOt;{tch6wLmO|*_+GS<&mw;xhZ?n7iL*AQi za>X7G2$x1}BpXaj_%(ryZ8%UO?H$TLMu7~7Hbg0N%8seMzukq2inCrkpY68ga z|D~<;6M)CrUju6B+=4f>YHWZ#ZE|dWjJAeb6<(B!|MFQNtLf~tw^U0He#x=CSe;V( zZ~Lyu{Ev3-7J89`xPd1A;kGM;wfyQCo$sL>@_No>C6N7^6=qm>30{N`qZoy3pqqZ> z@g<-ACW#MIOlnA8G!F$m-#mh^>coTUoQRMvgD z&fpxzU@PpXr`=!LNaXR;(*D$N2V>nOtP#XJ24|A?3fFRPEJN~)1y#1&fimaIKV+BxxkdATZ)l9a0x8udF_MGb zy&bwy3?!esg%=J7CbeZ-7k=2v8ZqZjw}n5C9_RI@*UB~ja`5r(JN8(At!-!s4{@Ko zFK%PQbo-t^)eBTPOGo|Q5G*S%=ZI>XDK$SkoAc`XBo1V5M2}P88TsgrUCBj(V;B;4AAoL3G|z+b|Sv}wTNF63-*c_!oGYV&sGw7 zrtt{e{)>x?hxK%t--2-)Z?D^YW3f|#eK)B-sLgOocu~Kq0K@m%(Q1$W?f>q-_49M} z!SjBm;d2+&Mn+ELQp^}iONX`Jz4)^|fCDvteeASD4Vuf(K}K3i#vjAQnRj`s`VEow zUB=z{S1rgFricdaPG_@KIN=mmIQxvkMXJ){kpU%H?_KuBIJz<5=--=3nTIZfu``Y0}jYo*2uB zJDlo4MF#i~Tv`X+!_%aP*K*+J zATD1Z7I~`U5x!ZS!)37TxxWEUqSJ9hhhzmAYr8Vg>2ijM_vGt@8fW#9G)3ATIu{7MAt z8yY#12@+INMwnZd4!XtCWREkH%~6I>P$kvsIHhtsb;>CDrCYDp-gsgOOcZA^rWVP? zLY3d_$8GN!5+S%e8W5yp9(#}Mfajv2WiSk=s9pn4VA&n~vyEt`x*m3_xmSO- zTinOrH2JNfLIgA;O>9{KRF=4v!N$?iNTqp38<&V^_WEG%uY>r(y$WQIMk55c$8Gv) zkEc$$--G+6qaa3Sn$j6TRfYOymKL#Wr!6qxD2fqmvtJv}?o%J-avU=mt!fZ6pYh2> zS>IeN%#|H$+Y^#O-X(j;W%&$3u)UYnsfC@Gk9p~P?lS$Iw?u=mnyvY@R-NZ3&*x(- z&Q{^V)oiC1-yhKoD)0xyveCTj#K$4z>rMH=D5e@678%l4LxCtMWlE9+G5H;(5*bTA z(oX?UCT^NWtAPSp5}4~WO6d&mJ5@H+@NF);V}kiFou`PW)Z;=(d${aY16+tTHK$Y| z9%;QMIK7beiR$v)+ucxR%?t_ zjoicVj?&(3Ex{`om+Q}PTUXassVYRfWIaK3wP)$*B6s3&`snNJ%CK=rh3jT%oXguuGa-_jq$! z4yFeLfF4QgljVigfAs>+7Cv%@+hns${s^NO%ZH_vl|`0DgYY0Q<9J=`FR5!fYz}7u z+WKQaBKGrB0uaRyPC#-GKz`y<1_lo(G+e3M?xIZ|Gp~9py)T&u(=}Is0a4Bv2 zD3vykIdV-)wcvRNeha&7c^Y{uNG+ZuP--5rtV%AWyq~K%6mC~8NQ21ui8=YNSh)0! zY{i{_J2@5wtl{8Me<7mXr?k_F6oC-WxYxa;!Mx1VeSP1W3X4~A=YEH1+|VOL zfu8Lyu=KjPToi11@!74?2A;y*_BN?$%+1ZHOD5UO6*ekljf46(hCqZ{1g1SzE-QYW zo0-|ZSb`n+Qs{`Vw{QhB$8Rh(&20D*DcSCUwgr#N;W7X*dBMzAI+cz4Ho7kA z`(E6JcJ)mEl|GM%XblFAk=D)d5gdA*fFD1~ii@4>?d2$u6ciLFL!ZW>w_1+{<@b32 zmv(Ci;_v$CnX|+={>_AI3j9 zF=++gDF{0 zbm?-kuCAc5xcqNZci(^Y- zephMv)%FcP2Vs#ESpac{(?zEtFg#abiXb{3kpb0nJSc(3nExz3D>y2{Slx7Dp~*Lk zhs914^les`6ixMs2vbzdEiAbZPl=yDd6=9}f`bzkh|jJ-?ls@=8BKuTZQI~%t!wS^ zpri;j7M-$-ON(kp*G4=Rza6>uIarT6K`81YkH}h#*--~;IiXm z0Up=n)kTpob3V6~oAD=OkFl4m=E!D7T1fM&O;Y{8O7@J9As(Vt90%>~j>dJ-XZgPf zCZ7AVQ|kF?>ca)RGK$8{foNY*w%V|$roeo%&Gp26aO9oa&LVTm#$KoNLJlmN2&QUp zH%{M|pxNy*m}6~0@lGtAN8X+NRCs}2E7tr7Y`kRdHlKtuLybC5(5f*lBB+HB5L&+U zNMIS-Dt(hejRHNEpH;j8X(HY&T`pQgLkg6C>~N4CO_KOXSgJ(Jq$p)ZC(1RiVY9R8 zOIvmNt2jtfmyG&H>rm24GmY) zp$xDiVlFN>Rd<*}y(cVrRkZeM*3BN%YAip2sUiA$STQ(|_yz<&P64AmQH=lx$5`iA zi|JOMQ~S@?z^pBMz0HCo2?ExfYzCd3$~l<=2~Uxbk(INjJX6;@Bf5aCVEFsqct?UB z8jzO!qv+*tyuF2uz9+y>3VbjV`=4&41oIQ|I9f!}CrA$ss;`X~E)jd+%6^sNQ&d#k zm_u8NitcOa>9HK?RK8WwhAvoGph+(Wq#(|4AK+eiPo~)S>({GM3phLl7+-}4bU{Mp z?I*8w1R-X8Z zh+_NkBjaO957#p;&+UpVNfd4@rCR7;R)W~lf0%ReHJ;Uds^1B=R6@}9 zj}f@px&PoX?gL!XB`hX8!f9?y931@W4P5cYZz(CHgwE1wJJXe65-Cu7!!=+ps86pG zUAv7q$^F!)Zf+cqm;c46!<8I?hr>zF$5)#~mMW=j|6?ae^>-Z}NBLu4o9d$PBKD#B zSBevnr53p@aft=Lu_V>XSdim-kl25d9as$TSY%RYx_#Gvs(Xcun?Tp6{UHjK*-dt| zw6RUj+#7{Kw0M1w6~C$A^1Y{ts`7}7)fq#L*2r2gDC>B7x>%~r2Ot@KXItDT4h}E0 zvT!%4=au6Htgfu7DM>+B1znqb=Oqt(6f7dhn0~+A7TQDHDU2z& zTTt;h_km{aq_Qp4^eso)J^NCgv0l#44hC7!UNzzu7o{fOV&!O@@;Ix(|J3l{P-kvcIV3)p#1o9W4F~|U& z(m~PQfOn;soQjU63%+Uv(&F|6B(2ibZ zaMAKN$mZB(iHmR%XpnMeD_Yegbp#Xfh`x6oedbj8#R#v?{8Hk6m(&M0q-Z$*x1d}l zW0&{`QRCwx+a2$={b+bfym#a`o05j-#tRWtK!(eDs&#&39BLA>tA`+t3#MyGdoL-JIR{HPbF zknE_32FtG*uVsIMfu7WaedXJnQ6?v%Ihwa5KMN#B9xDVEVI5YYm7S72H?T?y>t}b zz(gOkfcXyRi2r(@w2D>?y-O1urB22mQIQestaLpstD_iQK|4HUKqxkskd|iaYZPQu zqxh=i40q26A`~Ig?oHs~aUEVq#_45&C17Cy5cgcZ)_Z?EEG!4R`u#YH@*w?L%PpYUW7fp@jnU{laJ9d^+>~&auef`0vkq6-XIG#GTa%)we;v7k& z=Z-bM$Dn#*W4U*@14u71M`*ChN)_UhnAWVUEVjhu%j}U+?0D|d4^59jf8NOmV7}A$ zL};-o$UTiVWsmYIPdW(v!ybIXGB+t8X0~r{{EA2=0J#ZOaZtwc9hx`)ZDww^p)n6| zT^6&7^a8Q*HF^yV@I88+>zT7TVjjl;wgA-nQ)#|j+sgBzsX!@l0Ki&b48L&Tv$GFh z8s9`aw{IxCv5^ z|H0pSZ3LC@s_4{QU4=Y{pdHNGnu)o*x6$!AcLBHU^pA)m+Q_idaJV}~S0GKTy1s~r zs^7F$h6`uVa=3d2^}xqzIvI^dYMIFK6WcA`)#;JFW0X8R6&st=&ls8?qbBDNb$I)Y zKKjhU^_=8C>KWx9_?jFtV~~G$Cix-UoUxR^AH9H7d+p)V*&Wbrk=D=9-<6Ovls&=? zDrf~oVZ}8Edft4wUwy|W1zuGrD;8q{v1DFKMaSLlc^c4=*X5YF^3Sj-7qW_&m&@Ja zqO_nl(*Ccvm}ELeFXO3 zRIRX$cZ6X@hljv!7)b1E)&~M*1+#F|iau@*YeHkwN);(F!+&!FJdrjz@S1vUW>MJL z+4Zz_-1%qC;B+h=gXF=Hp9dkUd<=Q%+Y@Q{xHS zZM@pTdi2O1>qpUa>q?RkS+y9wPK62WsFtaE%)#k>$32z)UFW8Pirir)Z7LehW+Zy1 zB{I4E95}oLRubFWT^CB2f{Knm7-E=K)IDR-gXfq=gTLD`=~9$LI03aF$7}qC$Y{u3 zJsE+r-HvDEc1TobT70@(x)_!_V+U)tA)Fzy-eVrrAy$SCX-((bxh6{@>TI+?y3G7L z{mP?kL>9%oaY85$VO6g)HU>YhBFlU9jx@@6%r(mYCDs7QXb5!lcW}RZ53jWnE8>^dQ*$guHC`)%>(Azjf{-CquzSpA2qTzz5&3KsWQte$CYy6Ae|f+Yb!Hf zyFTHVV?S0i@I|!(lND56+P=aoHM;eR6(^aZ5%V9O>j&4K?do;n*A|-$fKBZ$nZlVj zzq+sil098%9PP)`D_@ zpD0|D$GC#dZ75xuX`tY2D?{L^gT2!Y;9InY932`gAv~D#+N%WsJ+w$Iug%r78yNgl z(^{v-`Cz?Jzy8`5X0!!-B;B3|K8JpOAW*(LTUrfwmrdjf3n{$lDWCntwsPPM$}qzo zU2AIrgYSJ}#9n%vLTiHA9f12X;Y^FUhTQ$7%3J7R;%VoQSpsCDY@%Gb-lvb ztj879?eyi)A$|gux3>1#HUSx%RQp_rk*qBZ(+TA3vs{y>yNu7O8_`pUlOu5$5!+0N-&k(-!y zcDz^eOq9nA=_L}WS`QrKDnpxcOeHF}J{3ATWIEPDLqXN3=s|bj{BG}lE%tYw!r0Ie zF`4`VgI0e_jhT`2ur*)Q=Piy@&~#q(He%d4k&oo|DjgbzS?MF1 zKcuQSjKTBXYzMOwc1_r`PeLl7VHgUFyeuiu;4T$^Qk683gQ1g|Z;e{P^XF_o;D_VC zT{bxOq?RN|RMLdaF#fEj4%cf-uM?M>nf}tVQR}Xna^1Q&t@&BM&2e*j!-Q-p z5zIa+UikQs40+S&x3AC822>Z+8|6S>bHw+Wvk7u<|We^d$= zkd2JY_WC56(JnrbeTbxaXlh|1r>iUHY$2%iyPAZQF6UR}0g~!E6*jZ-XTiWHYo6Z& z6K((m#^dmy4S4J7VP!r*@R{aekMgU~y!o9~6SgL>*Mp#>V&ioMyZ__kF9+~8%RbYD ziwjFts@rT?53MUbVkSfWdG2~(7)yd=WnZ+7Ae1bY(%voa?NUe*!L%v2@Q@oV2u<%sMuK9+#iy(u z*mF-8-3k~zhKd^G(O4V59SPSYiaiVGd*z&$_J zVs;V&9qT$0L^LBHsWuRPRJ$Ow9fB;04E) z?A3!xDTf%opPB07fkKbzo&m;fSl5bPAtw({-0cUwmLA8w8w3C;)S)%pF& z9|6FnE2^uuMei>^h1c5J#NO#V1mC2QpE#AFv6#1 z@lJ`ujq2k2IDhXew$ax78{Pg;2?+@;Z64l}<*Vo%MTK{p%?+Vf=jR6oA?Al#jauN9)$UM;6T0ZmlAj<2wJw8yg z6m&aXupdK~m056jx-vJAsbIMkXK$CI>r&%`kmdA0Xcu!9jZDFB=jYuX7b};re^ekh zUDmZ%a|GCDK5m*ZPF|d5hA5W$meb1_(9N*|h>+0KJUV{ELlE$Nf{dd5`;{YbQO|+Y z3+UAR=Fzk6!*ZFnHS=0LXuC~cUylF7dP&`#sj}gewqWsWf~^x=G$In$+%zHrB*-?= zOU7R}VY2+Dm*YuabNv00tC92Mw5_ZNwH}t!8YC2K@0x<)qjj*dqN32!RC*$9XDz5p z8{rf<-0bqXsWmABCJe_Y^Q`+)AVB-32PX|>AR<0uV?Fd|s9PUR`dTn7SpO^F6wKYMm6~Juh2>gS`yxiJ5-ii1_11l~ zra9V06U)m@vdj%k5g(kGs7y+tb83EE;Z~uY*Mfq_nkm)?m5gj=5n@jV^Ic|0tpv-F zEFYQcbC)@QXD< zLV}0%^!85^M_K>kCR(|Gdcm=YD_}JpE!LJff~Tq3`x%D#5aDxX&UgCzR@1+kjQ4bo z(sstmiC6w)F8MP=;*EKLwk^QOQOH20-oH1^RKeF!_a9-?Z+%upFM<;tOe_G0oe9V# z0y8q7+c7N_oeeX7&mzZ&v$E!OC#_1w(=>P{miWRV=p<#b(`(h3*gO@0uQ!#?LS+dZ zva5RCv+{{NIU9gIANcq;Ha0+uSxNhrKtiWqwF+9Ve+eYH0?7WG?h%4XQ|1MArv+X& zahCx=HFO#mk_-#YeV^S$Yqc2dJ#741jZ{EHrZ@lL#Gvxe9#E(o-*D{xk7dDnbhq z7Du+j*4G!ivbrj2Zhv{4HIh@G;!{%bG={YjB5>GFN-FwnZE|Yp!v~i`r0hX%WDD91 zZ~|9aAPAxxo81Ky0OLjTA@TqW>bj8@<)=iAmr+x*n6tbRUI|F(NjZnJvwBM-es;!v zY$YWBq(_t-0Mn(^d@NSwbCCh88CIc{-!ENp8U=COo3*q#!`y$=IyqHgqc7r9);q{v zFNimB1~7g{A3xQrWTcRc9Vm@Vr8k^cmbaCZ)h|9T=tX87CdQzr=j0A<*!xJVR6)E(%t{@QGCnJ& zOKl8iVT{UH2^}3&0zLaQGE5Hc;M(WgC((~Df=|($#}ms$PuNZXtqS5#%Aj#9)0+P5$j-0Dqkqwr z&5PclHLLgm5aEU@0@dT?g_N~@Te9tJZFB2i#}s5~q!g}vTvxJHNcwezYAN5+5> zNrN}HSmJ))YjQYahNo)FJ_6x>;*Nf977OiLd~XndAZ+j1p}?-cSj86$THXV;{xs@z zC^|zO7|$y}%;y>|qx(0Vl2cL=5ahI1dvl*<`r*y6qAaL&7FLWN)Y1-A9s|5cpg6W=Bzj7KykC;3`%!lO*W{3YQB!6Vv_*u4Z0mqm>}=pE#wRsnc1=(*I7R|AH;5<*WeARQ17d79NwcPocS4Tb#t zQnV;Xt^tlsQ7ZIwDR|hG)8#PLhQ-`-MN?RFzD!`#N^3-2!8spb(szWF@?7;kl|k7FB4OFFvx+5~HCdf|U0$|SRN9V728+q9>|$_M#fPb_Zc#>c8yv+Pd^UFYKWM^ z&>-#K=dbsEU~=pwq6>z9oNvq0Q}fokk8KgA2d{MT@G!511qG9-whK8Ag9YHi3*Ig< zCufb{MsqIgvRGsi2>Y8C187y)a@rQDsF+ET;ol7E(p&Qq%93RWK~`l)V$%~=stV>= zmssC;`VaN~2)N!V(4KR@qC0V4iEwGP#hd85H%b1GEAKVPxJX9P+pk2>J6Ly*3rEfU z3$tNDsa=orM5`MOn^}GK*mNlCYb-jK+u6$klUPWF839|Y|D6os{JlyJpA3Yx3cV7R zLV|@4d&+5~13i&4k@^xdOasZ{1-w50p~g2#QNkYV=&QIlGNL>@0Fcn4ewNMsulYM5 zkowN^wZuD~MQMPv&_?oP8VQzmYsxF%e4a9p+2S%BJWS-Z38j~oKgtQw$SW>dl^St4 zodzHkk8ku!Lk>1ojv~mqvvnMSj;q@*6=jDw91tD!3jd@H#$W2|058Dx#Sw6U%odAH zw!dKil0UcBdARN^F*6L(Po%jCNi*ui+JCHjX=yzpE^IG8eSQF1F53gezFPDE5!IKa z_=uG>Tg~xG$^rOfAZBT|5igcE7@Gp?BiEhfg;|CuES8e1d4)XsI+$eK;LyYNHB-QH zYB-vOc&H>+nt2WZ+_VF}-%zTZHx%ahJy;`cYea{uT#Z^;N^?&^w9=p_GZXN6P|l80 zP|aZO)wX1TQZrze0t#_f$^jOWah(h9!Nv7ub%sWsm4Rs-N40elpsysTOa#bX8;c$tpblBh=j`a9h*$4JF!3GuT) z@l7i3VH9K#oH0iG+d&Tpg)YgIxTHcSJwVTHRlzv^8~X-1O~2AeAfvMO!OCJQ#~X}E zzbe1{xmT_-S@sxJ>eEk5c>qIt2?z@Te1T8N{Hp=G-((3$=r7Dt(=!TqocHC{k4=41 ziIRu5ald(~~~Clox)sHjbxSliqvS?~3vNJ3u4i1%9J9d0GGEGG&H&3x7_DWvls`rdVwOMOvJurLfS__9?!g{^;ne0v$U+ z*ATlKHg;WSEf*IEl2zNTP_y}L7=XI!(<%KIQZZTzWxNU3K&w$Oi?A_E5sWuCG3ho^ zsZ+_Z+}kv}6d!xXppzY!$z&zjs-VuoTB(kQ!$4s?H;`i6l`^B&6KBX?3Q-gUrVlar z(Odz2yuc0U%FQ^FkN;7!y%<P}F!v7Ka%jWa|ePeBEhZi*i}aPmMK;|eZq|9t-AF)}tDobZtbTb1 zCqzS9Q^pP=oGy?)Q#CX)SGh@WzgsoKFLJtU_hPby8#^a62C)S1!6-PUH7y>ba$S89 z>B;JDMMHQQ$1x;_lt-m;4w%ugyn4ghHTYM6p4740oz~0gz`haJ!tU^vUD;tNKzVHW zn*qCLgsCW|-o%6kbo6j{ZoBr`ubsP~;Kc|}lAJi=lPYFm)!uZOghAkHRM_~PB(ydC z#!?VR9?vTRgYW~;d8k9gX;FK4qB#b)2j`QTd^ci$<|JW>-wdwsA$5Ss;KETZn>D{n z5A^U0ZyG*_q=>$}-Q7y9=isZdIRoMQ7hgn0O=?uOa&tvwf`aVLk{;i$_Q2lq)*I%| zpg$_;*7J!A?jA@yJu!xXlFrS3LlEaWw%$AZlu5u1n8j`(AhebZpYDU8p?b|!9Gl$J z;i{F77Tr8x9RV(f_3^Bsru+ASnW_i)=%zBx0Jr39J5nQFK1{RVskU*NEl`H1`=4Ob z)Qg?%4iDAz4d{!B1>t_k#rDIvJlz>;*S1VEGz4G~I+V&4WJ}xPH=t|>wS}1pLmA^% zx>+lTIo3&BzEEeujvh1ty)u!=C{VV}t?pG05>U0sJGioYiTp#z@c5mupu*u_ZLF@& z6`XUE*pkL}Vo@X*%bScB^;zz*6nPj^Q>_75Rt%linh%zsP(CVUJ=)*!C+U%VgslaG z*H#InU|#WJz}{2|m<4eVvR!-fc;?$MgwS7kxVy=x<*WM=Ez#{6k~R6Dv&8v1BVkbV zYxyv6NB~5%aONV;qVn?coE(~J*aTNhNr8hHq{^cWUqNU{VRNs0 zqPVWE&Y{&pm!TSt#hUT$zQ#)sxs+LCIS&Ed%ZuFr2Dtb_-E1cho+s{Q#6&6UH9uQ*V1Nw~WO z0lHVT9?l)r<$zJ;aW><`>c7gc5Z~Ue3UTXk=6T5CeII~_AmIHPt{4vQUoQHmej_j8 z^#9MvQ525|uEBWl0N{N^oYWE_3Avc@a4;G^A2qlAPsz{KQn~e0Q$f814ovmj>x1CH zg3QeG-Rd}1S>O(R*Hbr946%2#P3D%pI><;)PX1<8rQg;IXuOI~CDI0Ko|`3j_r=E) zxt1R2+p$YZEXc&NZrlO{S283sTAf%}lRILQL^^wVw4d`N22q!+0E!`7Nki*eFV`)3 z1E>ah7jC;Z@Utsm;>EzhQPt5A6@%70?$qUBfgv1y40W75?2i1}Fw3>=8nz?e;u5#P zeu0}yJNVR*X~A@{u~QU1(W>OsHPQ!ZLU|7l;uro`4!gq_@rTocX_tXsGgT0P1Qb+u zz#5x{U*`pzu_j6=OW~A(v$B z{7U)JPDW3RHEg6%O{9lctDjx*=GELef*LQB)fi$TymWVL<0fcn7FL9RuIpT~Pg-ey zsxfy-+ULGI0U8P0v$boEV_n{Lo#bm^o8;!vjlvHxI6`Zmsdh@_v{?kYXEUCIF}?!n z5J-(Pn^_I(FSV&Es{;N(z#6%~Tg=AK@p!z)CWe29$63mfS|6Kfi8-C!9{8*GYC&VS z9GDW<(gWi*wSJjOuQD8)a}7r(-0?Cl3bKMX94xn?W(gye^_!}z54IM>h<0c5;w~Jp z@0sv}C~j_`xi2cv;DCF(<9i5cEAQbGGPCt;cI|`OV00NtHvphop%_gFEe|Wph60K=uA$AZb>R-u(4-#m*|C`rI zaFR`i<3K63utEoYh0KfzH7$mleqC!Pr@eVOF9zYeuW;YV`&HZKzW0lpx5Nw~KiigK zPDa~V6w0ZGMT$VkHZ92JV)4`4A5r4=TQh##B90oOp7QeifoTmMcl~YfavU^XMphT9 zBl7g=^I^G2Q3?nSkKteP`}ve0`5Js43DKASlG3fSl|g!@ zq>1s*WH=c*oWsgt$u%fYtAuqWYOeT{fN}a!IjR0g#=vK&!y@+CytmW@I_wFL0LtmS zE#H?=$4!BBF|^e=0{=Cfaw-PM9Hh#O8|yWQwr1U&yT#Y{m*jdT`G z`=`hUk>4*uiuuFLqTgaZ$HsQHL;74D1=%O~ANV4~- zzvYGOY_1EHEehRQ?(Jx_e6-~a-%D4ryD5R6uAaoiU%g$%K{tJ(8?OEX{q+(;lb*_Z z|L$*gEw2aUFVVx}7L6V~3P28&?iyo;I++;!cBx9Iq(r+#qCo26DZR|I!DA50VnmDvi6HN8DpQ8vN*_b@8e<;AzSevAD#lSFHOBzxFGn zC6FAhjYKYyDg$UjAL9NY7BTByzDHbWaK60Xl6B}tWODa7Pd^5gV({JugH6Uwc5!4| zhCvr{L{ax~u3(-i=NQc^@jpXovN95VRB~9I%nAyrW`1;+n18IL zd(WxaRcGotesmts^z;tZ8*2^_>sSsE7?Hbt~&ZDN; z)2$~W!n09uVaA<7Q&SNp0LW&aeFN=nHR~fJyAs5|_tuk^^J! zg~OCGxul zX2)v5aDH|Psrxneq2PNCUP;E)qlXXn1{7Z9e*|(Aj#*k>dP|?6vV4V2^0QM+v=iKy zJ5j`6DnI6#J&-!!4*s~$IxRH3P&KceO7Ejsg!cFe|I83*?q4HaP5I_X#H1&tf*w0h z&2t`dUW;-`x|7%oB&3Kqf(7$|^Ak)|)DyB@dK2i;u8zOpOxwz1E#~asN=vw5Dig;& zgg6c!@;NTn_R!!5>r_bng?u`+^7k)#?u!xO_x6024(XSsJgstJqU$*vi4-Ha(qlFB z?_M=M^kJpvGd^j;DQ-2VqT6Boz)z`+w@kg{9$41R(Jz`P$Yq_mJ6XX|iF-&NP1QRb z^k9PuXUK+OU=vPo$P|R5HC~q^i6eBTJbJZy6QZ~$BY6L*-^9`L= zuA9^9-xzy+L{9kbeFkV?)nC5YtatyT_l35&{|<5zdGXwG#6+H19p}PBwDsm&%Z6fp zacEbipe~u+UK(l%dQCC>EN0PeZTVr3Ov!llDixgUlOQU1>^De1W#+U@<-`NTRWn9d zYde10HN64#lJRPeNv(RjF?*X%XEyIHf3) z!7c~>ok(xqT;&aAr2rqBnm5)~B;;h6K-bVkdN~A*r*%^sjo?K_PL9dqH+XOpZEmBa z7_|*zQZR+@2cQkqf{E*`%14>bP%jg;v}pG$DY0!YT=Yd)>qv`6HWyo6K&s z*k0oSrcw9L8BY|QzV}xJkOmH^Q4pul-n2tA<0l&8vh{} zOXH<12M0>ck}UMuk%(xuM%qc|DjMPtSvw|8VevN1!yW-NOp#G9!UG)Wi9c1ljquh*I6&`~MxsRY zM5PxVefWWPYsla%rBf?Lwc5Lqp59RUt^Dwi`UxzCLg3BSc!XNrOIA(?VjHcjZwH$} z&&i?^x>MC-aq&q{jo+%0aR6s1r|m4TID%#fm(33oL5myirapCMK0Y4cL1d$*_V?iB z<_7A&_~hi|lGHhH!>3ku$IQ>KlgSMnaCqpy+kt?_cn4NUflDQH1&`@wss%90!gK%Q zNK_@LV&fk^iC8&V*#Q7i#2^!_H0ePG)YhJfVs*>S#)5)=n?$3E+Ujc1Sie_H2UFMK zETuplpA*3UE)n8Eu&{Ba(oDmqTw*QJB9{%<|3vqNJL#?#hgg99Tj-j3sbrXZ-x@ND z_Lpvmlb%JR4t5L8JSCHt9O`4;IJ}p_ zm2f5KD&bR;3N`!xw7P|>X*;@gVo^Wa=3mTDbLlg2*Pp76Jl(#`DSm(Uqr)_0Y$0xH zmagANCh`ta&tsjiG03#ezcs=2H70{{ER{}>*fYx>Kbrp>6J!_in?evBrE1&5=vbS| z2XRHuX^rRVt-Y_~SQ7a)b2LcOYvt4w=zgZun)vH*Gmj5_S}`Qb<&V6=H_4~Y@2QIx zt`J^G=vvuAUW3FEf$NFq1O4>FU992wrtTFtqO_q$2_0ifxTB&aFzG?FXagbg($fVz zR7d9r;PU+(O}ClSYRlwFItw<+xOA?T@5Ka8cV%vXoo-UpS5@lRhi+emzxI#jwI+P{ zaJ|JQ6b9>IrelolGeP|l|Mun5@s~=|rSB^UwEXEgN0A{J-s%JdBm#0(ylf^$2Ho!% z%YqQU$HuG%yKRRdvOnmOeFReoYI}A2Ar=!OJQ*?=k^F?sP~16*)ge13zv&4c8$_{) zZ9(CJqM6NRe$`=xRwB0mf#Mal*X?QSwD3fw??VCpb|D7cj;9*uuZ#rhT|rH-e`p94 z+`H_BT6kYw{sHfUG`-6q6d!cWvr;fmQ`o3i`xDv87&WoU$o83?&kqlId~RwJ^6}9f z!L7~ao*XO68-PK+2GJ7GmJe;T9Nc`?pSr&Suot4 zyMd3lw{KaPDbf}H;e$o6nYPj>=Q?q6EY`LvRUgAJDs?kOrn#R6)xpszUPvP~sSGr~ zx}mW$t7Xg@t>tyo&2XQD+?ss@tw-JK(1>QQoW&PY0`*p&KAMLE|t9T?b!iG8`W|U%7B3 z$mKbcsL{&H%K$Rb_W42o1btzmCR^LDV|4sD&1qrgK$h5`Cb4-Kn<>opY<%6b{#b)t zi6`DcAfE*m{&-j-d3*K)&l2RwnYUyV@O45S!s|VNKTZgWO%ts*u(hB+0(UpZlnB&% zdor@w!LzEPcmlI?vnq6LD+B5qc5&n^=C>S(nuI({P6$mNj$Me7RMtMy8O>bsbVdxA zBK7pH`H*N+2tmivFBP9V3RSaG!n zIHlk4=`OxJ>oGnH;K*~cu$0|d#M1J(FXsdXc7d@gh-ym|=<;MeldeEKxZ+snj(kk& zY0dBb8`aReaq?>{`AB4bejdywIOB#Fergvo@`E1j8?aShSFc63db0I1Fe~GC7sf#v z*0;F;^1aKeOEdF(DQblale^i7zlLL$hGAq(au~xuYDHOE7Q2Il?ObmO6|||Uohep5 z5s}=FO;8RG=^fKlUT{-$OqW^leo58HX_s$p7cGh3Slggo@^~DpIdgfwn)icf&Tt^^ zR4bWW*VA=nnl?6GL9lI+~f13v!@|AJ8T#45pLW0T|>O+&Ty_Ie2MT|ztVJwnRh29 z_?lQ(b6NR!aIJ+nYmtbiAS2quU6rUY$w#I?wev}4P}lJ_Dft@Q_8$6!nBAxzd2Qe! zCnqN;arH{W0x^iC$#fq+0NsDQ_s^6xRW0rEg#g51e0_D7&qKvlFm^e9Z?55Vf8ide zWdOBT zt@9K$B#F4Y@6$$l0h)|f)wkV;KQ)rt>_02^nn>#@7(80gP5UZM%+2h^7FpKc`*Ci? z1tu#CmF;n@oUI+zQf|r0f>VJOeds%LXovEMkP^jKWu2`|qeUBolq2q-_z9L>XI%+Ie zlKZwOG|Irf+eF^f%)=vM(A=?{OFdVikT79?twY_Ern79ChJ`T}cD(w=j$n+9d8(CM zuXxPS-RtezS`~k=KQ)tz#f2WWd?TziNqSiD0`6>Pe2tZ;w5wwU<}XrQ)db3-qG zPsqT4QJPN3JRq7KFl*B}9E?EJ8$l*yCK#k7PAL�ba`Mw_Q&|Zg0GsE<23_)JjH1 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 List.last() || "" - - # Everything the channel has been shown. A wait looks here and not at the last message alone: + # Everything the channel has been shown, joined. "" and not nil when there is nothing yet: + # `nil =~ "x"` raises, which turns a wait into an immediate failure and hides the race it was + # written to wait out. A wait looks here and not at the last message alone: # the turn's reply and an approval raised during it arrive in either order, and a wait that # reads only the last one waits forever for whichever came first. defp shown, do: Console.text(@conv) |> Enum.join("\n") From c571639fac68d81441f821571e0fb209ccc390f8 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 22 Sep 2026 20:40:36 -0400 Subject: [PATCH 10/10] feat(s070): complete slice 070 (gateway core) Signed-off-by: Ayla Croft --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4082893..0b889bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -56,7 +56,7 @@ standards register names the rows that ask for them. | 060 | MCP client: Trinity's thin driver (2026-07-28 preferred, 2025-11-25 compat, MRTR, Tasks) | 6 MCP | L | 059, 021 | approved | | 061 | MCP server (stateless 2026-07-28 + compat, MRTR approvals, headless profile) | 6 MCP | M | 060, 024 | approved | | 062 | MCP authorization: OAuth client role, resource server, personal-profile authorization server (EMA removed 2026-09-22) | 6 MCP | L | 061 | approved | -| 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | in_progress | +| 070 | Gateway core: adapter behaviour, routing, PubSub fan-out | 7 Gateways | M | 012 | done | | 071 | Gateway: Telegram | 7 Gateways | S | 070 | planned | | 072 | Gateway: Discord (Nostrum) | 7 Gateways | S | 070 | planned | | 080 | Subagents + delegation | 8 Orchestration | M | 020, 023 | planned |