Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,34 @@ evidence for each increment is retained by the maintainers and summarised here.

## 2026-09-24

### `slice/042` — Rules your decisions imply
Trinity now notices when you have answered the same question the same way enough times, and offers to
write the rule so it stops asking. It offers it **in its own section, read in a calm moment**, and
never beside a request you are about to decide.

That placement is the whole design. The research this came from proposed showing a learned
recommendation next to the pending decision; the evidence on anchoring says a recommendation shown
before a person forms their own view moves the decision, and this project's permission model rests on
that decision being yours. Two further findings pointed the same way: people accept automated
suggestions without scrutiny, and between half and nearly all safety alerts are dismissed in the
clinical literature, because the cause of that is volume. So what is learned is spent on asking less
often rather than on leaning on the answer.

A rule is offered only when every past decision agreed. Nine allows and one refusal offers nothing,
because that rule would permit the case you refused; it is reported as a disagreement to look at
instead. Unanimity makes that guarantee true by construction rather than by arithmetic.

Two things enforce the placement. The module that does the learning is not visible outside the
permissions boundary, so nothing else can reach it and the compiler says so rather than a reviewer;
and a test plants a realistic attempt to annotate a pending approval through the one public way in,
and fails until it is removed. It is not behind a setting, because a setting that could put a
recommendation back beside a decision would be the same harm with a delay.

Accepting a proposal writes an ordinary rule, the kind you could have written by hand, and records it
in a new chain of its own for changes to standing authority, with the number of decisions it rested
on. A rule outlives the conversation that prompted it, so someone asking what this agent may do
without being asked, and since when, should not have to read every conversation to find out.

### `slice/005` — Transactions take the write lock up front
An intermittent "database busy" failure had been failing the build in a different test each time for
two days, across five pull requests. It is gone, for the reason it happened rather than around it.
Expand Down
2 changes: 1 addition & 1 deletion THIRD_PARTY_LICENSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ it, and a reader should not infer from an entry here that the component is in a
| `jose` | `1.11.12` | MIT | bom |
| `jsv` | `0.23.0` | Apache-2.0 | bom |
| `kernel` | `10.6.3.3` | Apache-2.0 | bom |
| `lazy_html` | `0.1.12` | Apache-2.0 | bom |
| `lazy_html` | `0.1.13` | Apache-2.0 | bom |
| `llm_db` | `2026.9.4` | Apache-2.0 | bom |
| `logger` | `1.20.4` | Apache-2.0 | bom |
| `luerl` | `1.5.1` | Apache-2.0 | bom |
Expand Down
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ slice_id percent sha date
014 80.31 1a5d480 2026-09-24
110 80.31 90c3d0a 2026-09-24
005 80.31 9e9259a 2026-09-24
042 80.31 714784f 2026-09-25
23 changes: 22 additions & 1 deletion lib/trinity/permissions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ defmodule Trinity.Permissions do
"""
use Boundary, deps: [Trinity], exports: [Policy, Approval, Rule, Fingerprint, Gate]

alias Trinity.Permissions.{Approval, Fingerprint, Gate, Policy, Rule, Store}
alias Trinity.Permissions.{Approval, Fingerprint, Gate, Learner, Policy, Rule, Store}

@type tier :: :read | :write | :exec | :network | :destructive | :ask
@type decision :: :allow | :deny | :ask
Expand Down Expand Up @@ -120,6 +120,27 @@ defmodule Trinity.Permissions do

defp impl, do: Application.get_env(:trinity, :permissions_policy, Policy.Layered)

## What past decisions imply (slice 042)

@doc """
Rules the owner's own decisions imply, strongest first.

`Trinity.Permissions.Learner` is deliberately **not** exported from this boundary, so nothing
outside the permissions context can reach it and the boundary compiler says so rather than a
reviewer. This is the only way in, and what comes out is a suggestion of a rule the owner could
have written by hand.
"""
@spec proposals(keyword()) :: [Learner.proposal()]
def proposals(opts \\ []), do: Learner.proposals(opts)

@doc "Tools whose past decisions did not agree, reported after the fact and never beside a pending one."
@spec divergences(keyword()) :: [Learner.divergence()]
def divergences(opts \\ []), do: Learner.divergences(opts)

@doc "How many consistent decisions before a rule is worth proposing."
@spec proposal_threshold() :: pos_integer()
def proposal_threshold, do: Learner.threshold()

## Requests and their decisions

@doc "Creates a pending approval for a call (a row, then a broadcast) and returns it."
Expand Down
156 changes: 156 additions & 0 deletions lib/trinity/permissions/learner.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
defmodule Trinity.Permissions.Learner do
@moduledoc """
What the owner's own decisions imply, spent on asking less often (slice 042).

This is the connectome research report's A1 and A4, reshaped by the evidence rather than
implemented as written. The report puts a learned recommendation at the arbitration point, beside
the decision the owner is about to make. Three findings say that is the one place it must not go:

* **Anchoring.** A recommendation shown before a person forms their own assessment moves the
decision. Trinity's permission model rests entirely on that decision being independent of what
the agent wants, so an anchor there is not a rough edge, it is an attack on the premise.
* **Automation bias.** Accepting suggestions without scrutiny, and then ceasing to monitor.
* **Approval fatigue.** Between 49% and 96% of safety alerts are dismissed in the clinical
literature, and human intervention succeeds in 9% to 26% of cases even once a problem has
surfaced. The cause is **volume**, so the intervention that helps reduces volume rather than
annotating it.

So the learned signal is spent on proposing a **rule**, reviewed in a calm moment, and on reporting
**divergence** after the fact. Both are decisions about the population of future asks rather than
nudges on a pending one. `test/trinity/permissions/learner_census_test.exs` asserts that nothing
this module produces reaches an approval card, and that census is the reason the module exists in
this shape.

## A proposal is unanimous or it is not a proposal

A rule is proposed only when every decision for that tool agreed. One dissent and it becomes a
divergence to look at instead, because a rule drawn from eleven allows and one deny would permit
the case the owner refused. That is the whole of `AC3`: a proposal can never imply a permission
broader than the decisions it was drawn from, and unanimity is what makes that true by
construction rather than by arithmetic.

## It reads decisions, and writes nothing but proposals

The source is the approvals table: rows the permission gate wrote when a person decided. The
learner has no write path into policy state or the effect catalogue, which a test asserts. What it
produces is a suggestion of a rule **the owner could have written by hand**, and accepting one
writes it through `Trinity.Permissions.put_rule/1`, the ordinary path, with no second kind of rule
anywhere in the tree.
"""

import Ecto.Query

alias Trinity.Permissions.Approval
alias Trinity.Repo

@default_threshold 5
@decided ~w(allowed denied)

@type proposal :: %{
tool: String.t(),
decision: String.t(),
count: pos_integer(),
first_seen: DateTime.t(),
last_seen: DateTime.t()
}

@type divergence :: %{
tool: String.t(),
counts: %{String.t() => pos_integer()},
total: pos_integer()
}

@doc "How many consistent decisions before a rule is worth proposing."
@spec threshold() :: pos_integer()
def threshold do
Application.get_env(:trinity, :permissions, [])
|> Keyword.get(:proposal_threshold, @default_threshold)
end

@doc """
The rules the owner's decisions imply, strongest first.

A tool appears only when every decision for it agreed and there are at least `threshold/0` of them,
and only when no rule already covers it: proposing what is already true is noise, and noise is what
makes a person stop reading.
"""
@spec proposals(keyword()) :: [proposal()]
def proposals(opts \\ []) do
minimum = Keyword.get(opts, :threshold, threshold())
covered = existing_rule_tools()

decided()
|> Enum.group_by(& &1.tool)
|> Enum.reject(fn {tool, _rows} -> tool in covered end)
|> Enum.flat_map(&propose(&1, minimum))
|> Enum.sort_by(& &1.count, :desc)
end

@doc """
Tools whose decisions did not agree, with the counts.

Read-only and reported after the fact, which is what makes it safe: a count of what has already
been decided cannot anchor a decision that has already happened.
"""
@spec divergences(keyword()) :: [divergence()]
def divergences(_opts \\ []) do
decided()
|> Enum.group_by(& &1.tool)
|> Enum.flat_map(fn {tool, rows} ->
counts = Enum.frequencies_by(rows, & &1.status)

if map_size(counts) > 1 do
[%{tool: tool, counts: counts, total: length(rows)}]
else
[]
end
end)
|> Enum.sort_by(& &1.total, :desc)
end

defp propose({tool, rows}, minimum) do
counts = Enum.frequencies_by(rows, & &1.status)

with 1 <- map_size(counts),
[{status, count}] <- Map.to_list(counts),
true <- count >= minimum do
times = rows |> Enum.map(& &1.inserted_at) |> Enum.sort(DateTime)

[
%{
tool: tool,
decision: rule_decision(status),
count: count,
first_seen: List.first(times),
last_seen: List.last(times)
}
]
else
_ -> []
end
end

# The approvals vocabulary is `allowed`/`denied`; the rules vocabulary is `allow`/`deny`. Mapped
# here rather than by trimming a letter somewhere, because two closed vocabularies that nearly
# match are exactly where a silent mistranslation lives.
defp rule_decision("allowed"), do: "allow"
defp rule_decision("denied"), do: "deny"

defp decided do
Repo.all(
from a in Approval,
where: a.status in ^@decided,
select: %{
tool: a.tool,
status: a.status,
inserted_at: a.inserted_at
}
)
end

defp existing_rule_tools do
Repo.all(from r in Trinity.Permissions.Rule, select: r.tool) |> MapSet.new()
end
end
11 changes: 11 additions & 0 deletions lib/trinity/receipts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ defmodule Trinity.Receipts do

@boot_key {__MODULE__, :boot_hash}
@boot_scope "boot"
@policy_scope "policy"

@doc "The scope of a session's chain."
@spec session_scope(String.t()) :: String.t()
Expand All @@ -41,6 +42,16 @@ defmodule Trinity.Receipts do
@spec boot_scope() :: String.t()
def boot_scope, do: @boot_scope

@doc """
The chain for changes to standing authority (slice 042).

A rule outlives the session that prompted it, so it does not belong in that session's chain: a
reader asking "what may this agent do without being asked, and since when" should not have to
walk every conversation to find out. Its own scope keeps the answer in one place.
"""
@spec policy_scope() :: String.t()
def policy_scope, do: @policy_scope

@doc "Appends a receipt to a scope, starting its writer if needed. See `ChainWriter.append/2`."
@spec append(String.t(), map()) :: {:ok, Receipt.t()} | {:error, term()}
def append(scope, attrs) when is_binary(scope) and is_map(attrs) do
Expand Down
Loading
Loading