From ab35c676243f803d6e31525dd2667e3198572206 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 23 Sep 2026 12:12:49 -0400 Subject: [PATCH 1/6] docs: record the OpenSSF Best Practices passing badge The project was assessed against the OpenSSF Best Practices criteria at the passing level and earned the badge on 2026-09-23: basics 13/13, change control 9/9, reporting 8/8, quality 13/13, security 16/16, analysis 7/8. It is the prerequisite the Linux Foundation names for a project entering Incubation. The badge is in the README and, as a row in the assurance posture table, names what backs it. docs/09-standards-register.md carries the row with its evidence path, the three suggested criteria recorded unmet rather than stretched (version_semver, dynamic_analysis, dynamic_analysis_enable_assertions), and a note naming the answers that were true on the date given and are not permanent: the two about received reports, the vulnerability response window, and the 60-day clock on the open glib advisory recorded as R25. Those are re-checked when the badge is revisited rather than assumed to still hold. The audit behind the self-certification found and fixed a real weakness on its first pass: gateway pairing codes were drawn from a non-cryptographic generator (fixed in 4a0b9c3). Signed-off-by: Ayla Croft --- README.md | 3 +++ docs/09-standards-register.md | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/README.md b/README.md index fa319c6..6d2d2e4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Trinity +[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/14772/badge)](https://www.bestpractices.dev/projects/14772) + A personal AI agent that runs on your own machine. It remembers you, learns procedures, acts through tools under a permission gate, reaches you on whatever surface you are using, and does not lose your work when something crashes. @@ -169,6 +171,7 @@ rather than described by it. | Approved cryptography | A dedicated CI leg builds from source and runs the cryptographic properties inside a FIPS-mode container, so statements about approved algorithms are measured on that leg rather than asserted. | | Supply chain | Dependency and licence audits run on every commit; dependency versions are pinned in `VERSIONS.md` and verified against the lock file by the gate. | | Provenance | Every commit carries a Developer Certificate of Origin sign-off, enforced by a hook and independently by CI. | +| Independent self-certification | The project holds the [OpenSSF Best Practices passing badge](https://www.bestpractices.dev/projects/14772), assessed against the Open Source Security Foundation's published criteria for basics, change control, reporting, quality, security and analysis. Three suggested criteria are recorded as unmet rather than stretched: semantic versioning, which begins at the first supported release, and two concerning dynamic analysis tooling, which the project does not yet run. | | Claim discipline | `docs/09-standards-register.md` records one row per control a regulated deployment might ask about, each with an evidence path and a status. No public claim about a regulation or requirement is made without a row there saying it is true. | ## Milestones diff --git a/docs/09-standards-register.md b/docs/09-standards-register.md index f6faaf9..fe718bd 100644 --- a/docs/09-standards-register.md +++ b/docs/09-standards-register.md @@ -50,6 +50,20 @@ document, a PROOF.md line) or `none`; the status; who decides the status. Status | Trinity issues no production authority | The production profile validates the external issuer's tokens and mints none; the personal profile's issuer refuses to start under an external authority adapter and its tokens are marked and refused in production | `test/trinity/mcp/auth/embedded_test.exs` (refused at boot; no key material in production), `test/trinity/mcp/auth/token_test.exs` (the mark refused) | tree property, held by tests since 2026-09-22 | owner | | Enterprise Managed Authorization (ID-JAG) | Not in this tree: the external authorization server redeems the assertion (owner decision 2026-09-22, slice 062 NOTES "Deferred") | docs/08 row; slice 062 NOTES | `not claimed`; deferred with a lift condition | owner | +## Open source assurance + +| Control | Where Trinity satisfies it | Evidence path | Status | Decider | +|---|---|---|---|---| +| Recognised open-source security baseline | The OpenSSF Best Practices criteria at the passing level: basics, change control, reporting, quality, security and analysis | https://www.bestpractices.dev/projects/14772 | **passing, self-certified 2026-09-23.** Three suggested criteria are recorded unmet rather than stretched: `version_semver`, `dynamic_analysis`, `dynamic_analysis_enable_assertions` | maintainer | +| Linux Foundation Incubation prerequisite | The same badge; LF Incubation requires it at passing, alongside documented technical governance and a README per repository | https://www.bestpractices.dev/projects/14772, `GOVERNANCE.md`, `MAINTAINERS.md` | badge held; neutral asset hosting is not yet in place and is a foundation-side step | owner | + +**Time-bound answers in that self-certification.** Three of the answers were true on the date given +and are not permanent: `report_responses` and `enhancement_responses` ("no external reports received +yet"), `vulnerability_report_response` ("none in the last 6 months"), and +`vulnerabilities_fixed_60_days`, which depends on the age of the open `glib` advisory recorded as R25 +in `docs/06-risk-register.md`. They are re-checked when the badge is revisited rather than assumed to +still hold. + ## Supply chain | Control | Where Trinity satisfies it | Evidence path | Status | Decider | From f77bde291359f67f270ff4eaa33994e1dd1f2774 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 23 Sep 2026 12:17:18 -0400 Subject: [PATCH 2/6] docs: state the passing badge in the README's status section The badge image alone says little to a reader who does not recognise it. The status section now states what was assessed, by whom, that it is the Linux Foundation's named prerequisite for Incubation, and that three suggested criteria are recorded unmet rather than stretched, with a pointer to the assurance posture table that says what the build enforces. Signed-off-by: Ayla Croft --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 6d2d2e4..4cdd7e0 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,15 @@ authorization), and the first slice of M5b (070, the gateway core): 25 slices, e merge commit and tagged `slice/NNN` (`git tag -l 'slice/*' | wc -l` → 25, on 2026-09-23). What that means in practice: +**Assessed against a recognised baseline.** Trinity holds the +[OpenSSF Best Practices passing badge](https://www.bestpractices.dev/projects/14772), the Open +Source Security Foundation's published bar for basics, change control, reporting, quality, +security and analysis, and the prerequisite the Linux Foundation names for a project entering +Incubation. Every required criterion is met; three *suggested* ones are recorded as unmet rather +than stretched, because a sheet with nothing on it survives a spot check less well than one that +says where the gaps are. The [assurance posture](#assurance-posture) below states what the build +enforces and what enforces it. + - **Talks.** Streaming chat with any provider behind one behaviour (`Trinity.LLM`), switched by configuration; the assistant's text is persisted as a draft every 500 ms or 2 KB while it streams, so a crash mid-turn loses at most that much. Context compaction with lineage when a conversation outgrows the From b325cd2f33484455ac8a6dadddf1c00f78c022f5 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 23 Sep 2026 12:23:57 -0400 Subject: [PATCH 3/6] docs: add the assurance case An assurance case is a structured argument that a system's security claims hold, with the evidence for each named so a reader can check it rather than take it on trust. It is the artefact a software assurance reviewer is trained to read, and the material for one already existed here in pieces: the security model, the standards register, the risk register and the tests. docs/10-assurance-case.md states one top-level claim - an action with an effect outside the conversation cannot happen unless a decision allowed it, and what happened can be reconstructed from records that cannot be silently altered - and decomposes it into ten claims. Each carries its argument, the files, tests or commands that evidence it, and the limit on what it covers. The limits and gaps are part of the document rather than omitted from it: the gate decides but does not sandbox; a chain signed by a key in a file proves integrity and not custody; memory safety is claimed for this project's code and not for the runtime beneath it; and the project does not yet publish a signed SBOM, which is named as weaker than current federal guidance asks. The assumptions are named too, because an assurance case that hides them is an advertisement: the machine is trusted, the BEAM is not an OS sandbox, key custody is the operator's, the model provider is not trusted for safety, and an external authority layer is outside the case. Cross-referenced from the README's assurance posture and repository table and from docs/07, so it is findable by the reader it is written for. Signed-off-by: Ayla Croft --- README.md | 2 + docs/07-security-model.md | 3 + docs/10-assurance-case.md | 206 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 211 insertions(+) create mode 100644 docs/10-assurance-case.md diff --git a/README.md b/README.md index 4cdd7e0..eee1477 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ rather than described by it. | Supply chain | Dependency and licence audits run on every commit; dependency versions are pinned in `VERSIONS.md` and verified against the lock file by the gate. | | Provenance | Every commit carries a Developer Certificate of Origin sign-off, enforced by a hook and independently by CI. | | Independent self-certification | The project holds the [OpenSSF Best Practices passing badge](https://www.bestpractices.dev/projects/14772), assessed against the Open Source Security Foundation's published criteria for basics, change control, reporting, quality, security and analysis. Three suggested criteria are recorded as unmet rather than stretched: semantic versioning, which begins at the first supported release, and two concerning dynamic analysis tooling, which the project does not yet run. | +| Argued, not asserted | `docs/10-assurance-case.md` decomposes the top-level security claim into ten claims, each with its argument, the evidence a reader can check, and the limit on what it covers; the assumptions the case rests on are named rather than implied. | | Claim discipline | `docs/09-standards-register.md` records one row per control a regulated deployment might ask about, each with an evidence path and a status. No public claim about a regulation or requirement is made without a row there saying it is true. | ## Milestones @@ -210,6 +211,7 @@ later without renumbering anything. | `docs/` | Vision, architecture, tech stack, conventions, data model, risks, security model, standards register; packaging, the FIPS leg, backup and restore, performance measurements | | `docs/mcp-server.md` | Connecting a client to Trinity's MCP server (Claude Code, VS Code, Codex, goose), stdio, approvals over the wire, the headless profile | | `docs/adr/` | Architecture decision records. One is added whenever a decision changes | +| `docs/10-assurance-case.md` | The structured argument that the security claims hold, with the evidence for each and the assumptions and limits named | | `docs/09-standards-register.md` | One row per control a regulated deployment may ask about, with its evidence path and status | | `lib/`, `test/`, `config/` | The application | | `src-tauri/` | The native desktop shell | diff --git a/docs/07-security-model.md b/docs/07-security-model.md index 5b70545..def93c2 100644 --- a/docs/07-security-model.md +++ b/docs/07-security-model.md @@ -1,5 +1,8 @@ # 07: Security model +The structured argument that these mechanisms deliver what they claim, with the evidence for +each claim and the assumptions it rests on, is `docs/10-assurance-case.md`. + ## Trust boundaries ``` diff --git a/docs/10-assurance-case.md b/docs/10-assurance-case.md new file mode 100644 index 0000000..6de57fa --- /dev/null +++ b/docs/10-assurance-case.md @@ -0,0 +1,206 @@ + + +# 10: Assurance case + +An assurance case is a structured argument that a system's security claims hold, with the evidence +for each claim named so that a reader can check it rather than take it on trust. This document is +Trinity's. + +It is organised as claims, each with the argument for it and the evidence that supports it. Every +piece of evidence is a path in this repository, a test that can be run, or a command that produces +the stated output. Where a claim has a limit, the limit is stated with the claim rather than left +for a reader to discover. + +**The top claim.** *An action with an effect outside the conversation cannot happen unless a +decision was made to allow it, and what happened can be reconstructed afterwards from records that +cannot be silently altered.* + +Everything below decomposes that claim. The scope is the software in this repository. It excludes +the machine's own security, the model provider's conduct, and any external authority layer a +deployment supplies, each of which is named as an assumption at the end. + +--- + +## C1. No effect happens without a decision + +**Argument.** Every tool call is decided before it runs. The decision is made by one component, +recorded before the effect is attempted, and cannot be bypassed by the surface that requested it: +approval surfaces carry no authority of their own, they only report a decision made elsewhere. + +**Evidence.** +- `lib/trinity/permissions/gate.ex` is the single decision point; `lib/trinity/effects/runner.ex` + asks it once per validated call and writes a decision receipt before anything runs. +- A decision that cannot be receipted refuses the call, so an unrecorded effect is not a + possibility the code allows. +- `test/trinity/permissions/gate_test.exs`, `test/trinity/permissions/session_flow_test.exs`. +- The tier of a call is code, not configuration supplied by a caller: a runtime-registered tool + cannot claim a core tool's name or lower its own tier. + +**Limit.** The gate decides; it does not sandbox. A tool that is allowed to run does run, with the +privileges of the operating-system user. Isolation of executable content is a separate mechanism +(`Trinity.Sandbox`) and is not claimed here. + +## C2. Identity is separated from authority + +**Argument.** Establishing who is calling and deciding whether an effect may happen are different +questions answered by different components. A credential never carries an entitlement. + +**Evidence.** +- ADR-0008 records the decision and its consequences. +- `Trinity.MCP.Auth` authenticates a caller and answers with a principal, never a permission; the + permission gate still decides every call that principal makes. +- A token's scopes are checked *before* the gate is consulted and never in place of it: a scoped + token is permission to ask, not permission to act + (`test/trinity/mcp/auth/resource_server_test.exs`). +- An approval arriving from a chat channel is capped below what the local desktop may approve, and + the cap is applied *after* the gate's own decision, never instead of it + (`lib/trinity/gateways/cap.ex`, `test/trinity/gateways/approvals_test.exs`). + +**Limit.** Where a deployment supplies an external authority layer, this tree keeps no executor for +the effects that layer governs; what that layer decides is outside this case. + +## C3. Content from outside the machine is never treated as instruction + +**Argument.** Anything Trinity reads from the world — a web page, a file, a tool result, another +agent's response over the protocol — is marked at the boundary where it enters and stays marked. +The model sees it as data. + +**Evidence.** +- `lib/trinity/tools/untrusted.ex` marks content at the boundary with its origin and source. +- The persona instructs that tool results and web pages are data and never instructions, and the + provenance rules in `docs/07-security-model.md` state where the mark is applied. +- Model output reaches the interface through one rendering path, so an injection cannot escape + into markup by a second route. + +**Limit.** Marking is not proof against a model choosing to follow instructions it was told to +ignore. The mitigation that does not depend on the model's judgement is C1: whatever the model is +persuaded to attempt still meets the gate. + +## C4. Model output is never executed + +**Argument.** No path evaluates text a model produced. This is enforced by a tool over the whole +tree rather than by review. + +**Evidence.** +- `credo_checks/no_eval_on_model_output.ex`, a project-specific static analysis rule covering + `Code.eval_string` and its family, run in the quality gate on every commit. +- `test/no_eval_on_model_output_test.exs` holds the rule itself. +- `docs/03-conventions.md`, engineering rules. + +## C5. The architecture is enforced rather than described + +**Argument.** The module layering in `docs/01-architecture.md` is compiled. A dependency that +violates it is a compile error, so the document cannot drift from the code. + +**Evidence.** +- `boundary` runs as a compiler and the gate compiles with warnings as errors, so a violation fails + the build. +- The authorization package is declared with no dependency on the rest of the tree, and the + refusal of a planted violation is reproducible: add a call to `Trinity.Sessions` inside + `lib/trinity/mcp/auth/` and `mix compile --warnings-as-errors` exits 1 naming it. +- `test/trinity/mcp/auth/boundary_test.exs` holds the declaration and a source census beside it. + +## C6. What happened can be reconstructed + +**Argument.** Decisions and effects are written to an append-only hash chain, each entry signed, +with periodic checkpoints, and a verifier that detects alteration. + +**Evidence.** +- `lib/trinity/receipts/` — the chain writer, the signer (Ed25519), checkpoints, and + `verifier.ex`. +- `test/trinity/receipts/verifier_test.exs` and `standalone_verifier_test.exs`: the verifier + detects a tampered entry, and it runs without the application so an auditor need not trust the + program that wrote the records. +- Receipts carry the caller's issuer, subject and scope where the call arrived over the protocol, + and never the credential itself. + +**Limit, stated plainly.** A chain signed by a key held in a file proves the records were not +altered after the fact. It does not prove custody of the key. Key custody is a separate concern +and is not claimed here. + +## C7. Cryptography is standard, correctly sourced, and measured + +**Argument.** Only published, reviewed algorithms are used; none is implemented in this project; +keys and nonces come from a cryptographically secure generator; and the claims about approved +algorithms are measured on a dedicated build rather than asserted. + +**Evidence.** +- Ed25519 (RFC 8032), SHA-256 (FIPS 180-4), AES-256-GCM (NIST SP 800-38D), and the JOSE algorithms + ES256, EdDSA and RS256 (RFC 7518, RFC 8037). All performed by Erlang/OTP's `:crypto` (OpenSSL) + and the JOSE library. +- All keys, nonces and credentials from `:crypto.strong_rand_bytes/1`. A census test forbids the + non-cryptographic generator in the gateway package + (`test/trinity/gateways/identities_test.exs`), added after an audit found a pairing code drawn + from `Enum.random/1`; the fix is in the history and is not hidden. +- A continuous integration leg builds from source and runs the cryptographic properties inside a + FIPS-mode container (`docs/fips-leg.md`, the `fips` job). + +## C8. The supply chain is controlled and the provenance of changes is established + +**Argument.** Dependencies are pinned and audited; every change carries an attested author; and no +change reaches the main branch without the full gate. + +**Evidence.** +- Versions pinned in `VERSIONS.md` and verified against the lock file by the gate; `hex.audit` and + `deps.audit` run on every commit. +- Every commit carries a Developer Certificate of Origin sign-off, enforced by a local hook and + independently by CI, so a bypassed hook still fails the build. +- The main branch is protected: changes merge only through a pull request with the gate green. + +**Gap, stated rather than omitted.** The project does not yet publish a signed Software Bill of +Materials. It is planned work, and until it exists this claim is weaker than current federal +guidance asks for. + +## C9. Memory safety by construction + +**Argument.** The classes of defect that dominate vulnerability data in memory-unsafe languages do +not arise in this tree, because of the languages it is written in rather than because of diligence. + +**Evidence.** The application is Elixir on the BEAM; the desktop shell is Rust. Both are +memory-safe. There is no C or C++ in the project's own code. + +**Limit.** Dependencies below the runtime — OpenSSL, the BEAM itself, the GTK stack the desktop +shell links — are written in memory-unsafe languages. The claim is about this project's code. + +## C10. Secrets do not enter the repository or the records + +**Argument.** Credentials live in the environment or the operating system's keychain, never in the +tree, and are kept out of the records the system writes. + +**Evidence.** +- A secrets scan runs in the quality gate on every commit; `.env*` is excluded from version + control. +- No credential material reaches a receipt, a log line, or the model's context: the authorization + layer answers with a principal and never the token, and tests scan the records and the session's + messages for credential material and find none. + +--- + +## Assumptions + +An assurance case that does not name its assumptions is an advertisement. These are Trinity's, and +each is outside the software's control. + +1. **The machine is trusted.** Trinity runs as the operator's user and protects them from what it + reads, not from themselves or from an attacker who already controls the machine. +2. **The BEAM is not an operating-system sandbox.** It isolates processes from each other; it does + not confine what a permitted tool may do to the filesystem or the network. +3. **Key custody is the operator's.** See the limit under C6. +4. **The model provider is not trusted for safety.** Every claim above is arranged so that a model + behaving badly is contained by the gate and the membrane rather than by its own compliance. +5. **An external authority layer, where a deployment supplies one, is outside this case.** + +## What is not claimed + +`SECURITY.md` states the boundaries in the project's own words. In summary: no claim is made about +resistance to an attacker with local privileges, about the sandboxing of executable content before +the sandbox slice, or about any regulatory compliance not carrying a row in +`docs/09-standards-register.md` with an evidence path and a status. + +## How to check this document + +Every claim above names files, tests or commands. `mix gate` runs the checks referenced throughout +in one command. The standards register records which external requirements are claimed and which +are not, and the risk register records what is known to be wrong and what would lift it. If a +claim here cannot be checked by a reader from those artefacts, that is a defect in this document +and worth reporting as one. From 85433176f41dcfbc55e3b275092e915d5ef53147 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 23 Sep 2026 12:33:10 -0400 Subject: [PATCH 4/6] fix: let a queued SQLite writer wait rather than fail The suite deliberately runs fifty concurrent conversations (slice 070 AC7) and twenty concurrent writers (slice 010 AC2), on a sandbox where every process a test starts shares its owner's connection. That contention is the point of those tests. SQLite governs it with busy_timeout, which was left at the driver's default of two seconds, while the Postgres branch of the same configuration had already raised queue_target for the same reason: a writer queued on purpose should wait, not fail. The result was an intermittent Exqlite 'Database busy' on loaded CI runners and never locally, in a different test each time - the signature of contention rather than of a defect in whichever test lost. It failed the gate on pull requests 67 and 70; on 67 it was re-run and passed, which is the wrong response and is why it came back. busy_timeout is now 30 seconds on both SQLite test repositories, matching the intent already written into the Postgres branch. Signed-off-by: Ayla Croft --- config/test.exs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/config/test.exs b/config/test.exs index 959926b..1998745 100644 --- a/config/test.exs +++ b/config/test.exs @@ -133,18 +133,28 @@ else # connection (run of 2026-09-22). Every test still shares its owner's single connection # with every process it starts (shared mode), so 010's "writers queue on one connection" # holds within a test; the second serves the boot-time check alone. + # `busy_timeout` for the same reason the Postgres branch above raises `queue_target`: a writer + # queued behind another on purpose should wait, not fail. DBConnection's queue settings govern + # checkouts, but SQLite's own lock contention is governed by this, and the driver's default of + # two seconds is short for a suite that deliberately runs fifty concurrent conversations + # (slice 070 AC7). Leaving it at the default produced an intermittent `Database busy` on the + # loaded CI runners and never locally: runs 35864366007 and 35878… on pull requests 67 and 70, + # in a different test each time, which is the signature of contention rather than a defect in + # the test that happened to lose. config :trinity, Trinity.Repo, database: Path.expand("../trinity_test.db", __DIR__), pool: Ecto.Adapters.SQL.Sandbox, pool_size: 2, queue_target: 5_000, - queue_interval: 30_000 + queue_interval: 30_000, + busy_timeout: 30_000 config :trinity, Trinity.Repo.Receipts, database: Path.expand("../trinity_test_receipts.db", __DIR__), pool: Ecto.Adapters.SQL.Sandbox, queue_target: 5_000, - queue_interval: 30_000 + queue_interval: 30_000, + busy_timeout: 30_000 end # We don't run a server during test. If one is required, From 52e122a5e54638a9a10d634241b162d255e1fdcd Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 23 Sep 2026 12:36:25 -0400 Subject: [PATCH 5/6] fix: correct the previous commit, and guard the shipped busy_timeout properly Correction, and it matters because the record should not carry a false reason. The previous commit said busy_timeout had been 'left at the driver's default of two seconds'. That is wrong: config/config.exs has shipped busy_timeout: 5_000 for both repositories since slice 010. The change it made was to raise the value in the test environment from 5 s to 30 s, not to set one that was absent. The reason for raising it stands - a suite that queues fifty writers on one connection on purpose should wait rather than fail on a loaded shared runner - but the stated reason was untrue and is corrected here rather than rewritten. It also broke test/trinity/repo_config_test.exs, which asserted the live value was 5000 and so was reading the suite's own configuration while describing itself as guarding what ships. That test now reads the shipped value from config/config.exs under the production environment, exactly as the pool-size test beside it already does and for the same reason, and separately asserts the suite's value is never below it. The shipped configuration is unchanged and is now genuinely guarded. Signed-off-by: Ayla Croft --- config/test.exs | 18 ++++++++++-------- test/trinity/repo_config_test.exs | 14 ++++++++++++-- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/config/test.exs b/config/test.exs index 1998745..1939d3e 100644 --- a/config/test.exs +++ b/config/test.exs @@ -133,14 +133,16 @@ else # connection (run of 2026-09-22). Every test still shares its owner's single connection # with every process it starts (shared mode), so 010's "writers queue on one connection" # holds within a test; the second serves the boot-time check alone. - # `busy_timeout` for the same reason the Postgres branch above raises `queue_target`: a writer - # queued behind another on purpose should wait, not fail. DBConnection's queue settings govern - # checkouts, but SQLite's own lock contention is governed by this, and the driver's default of - # two seconds is short for a suite that deliberately runs fifty concurrent conversations - # (slice 070 AC7). Leaving it at the default produced an intermittent `Database busy` on the - # loaded CI runners and never locally: runs 35864366007 and 35878… on pull requests 67 and 70, - # in a different test each time, which is the signature of contention rather than a defect in - # the test that happened to lose. + # `busy_timeout` raised above the shipped 5 s (config/config.exs), for the same reason the + # Postgres branch above raises `queue_target`: a writer queued behind another on purpose should + # wait, not fail. DBConnection's queue settings govern checkouts; SQLite's own lock contention + # is governed by this. Five seconds is right for a desktop application and short for a suite + # that deliberately runs fifty concurrent conversations (slice 070 AC7) on a loaded shared + # runner, where it produced an intermittent `Database busy` that never appeared locally, in a + # different test each time - the signature of contention rather than of a defect in whichever + # test lost. The shipped value is unchanged and is still guarded by + # test/trinity/repo_config_test.exs, which reads it from the file rather than from this + # override. config :trinity, Trinity.Repo, database: Path.expand("../trinity_test.db", __DIR__), pool: Ecto.Adapters.SQL.Sandbox, diff --git a/test/trinity/repo_config_test.exs b/test/trinity/repo_config_test.exs index aae0603..0a970d2 100644 --- a/test/trinity/repo_config_test.exs +++ b/test/trinity/repo_config_test.exs @@ -36,13 +36,23 @@ defmodule Trinity.RepoConfigTest do assert %{rows: [[1]]} = Trinity.Repo.query!("PRAGMA foreign_keys") end - test "busy_timeout is the configured 5000 ms, and the pragma cannot show it" do + test "busy_timeout is the shipped 5000 ms, and the pragma cannot show it" do # exqlite installs its own busy handler through sqlite3_busy_handler and applies the # timeout with sqlite3_busy_timeout on its side of that handler, so `PRAGMA busy_timeout` # reads 0 on every connection it opens (deps/exqlite/lib/exqlite/connection.ex, the # comment above set_busy_timeout/2, at the locked 0.40.0). The config value is the one # the driver applies; contention itself is exercised by the slice 010 stress test. - assert Trinity.Repo.config()[:busy_timeout] == 5000 + # The shipped value is read from the file under the production environment, as the pool + # size above is and for the same kind of reason: the suite raises this one, because tests + # that queue fifty writers on one connection on purpose (slice 070 AC7) should wait rather + # than fail on a loaded runner. Asserting the live value here would assert the suite's + # override and stop guarding what ships. + config = Config.Reader.read!("config/config.exs", env: :prod) + assert config[:trinity][Trinity.Repo][:busy_timeout] == 5000 + assert config[:trinity][Trinity.Repo.Receipts][:busy_timeout] == 5000 + + # And the suite's own value is at least the shipped one, never below it. + assert Trinity.Repo.config()[:busy_timeout] >= 5000 assert %{rows: [[0]]} = Trinity.Repo.query!("PRAGMA busy_timeout") end From 5118742cc6d7fa7e1ccd55cff6c0b47a68275898 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 23 Sep 2026 12:47:12 -0400 Subject: [PATCH 6/6] docs: record the Database busy flake as R26, and correct what the timeout fix achieved The busy_timeout raise in the previous commit did not stop the intermittent failure. Run 35889922107 failed on the very commit that raised it, while the pull-request run on the same commit passed, so it is seed- and load-dependent and the raise treated the wrong mechanism. Why no timeout can fix it: SQLite returns SQLITE_BUSY immediately, without consulting the busy handler, when a connection holding a read transaction tries to upgrade to a write while another connection holds the write lock, because waiting there could deadlock. R26 records it with what provokes it (two connections, and fifty concurrent writers queued on purpose at slice 070 AC7), what has been ruled out, three candidate fixes not yet taken, an owner, and a lift condition a stranger can check: ten consecutive green runs of the full suite with the stress test still in the default run. The raise is kept because it helps ordinary lock waiting, and is no longer described as the fix for this. Signed-off-by: Ayla Croft --- config/test.exs | 8 ++++++++ docs/06-risk-register.md | 1 + 2 files changed, 9 insertions(+) diff --git a/config/test.exs b/config/test.exs index 1939d3e..0ea6477 100644 --- a/config/test.exs +++ b/config/test.exs @@ -143,6 +143,14 @@ else # test lost. The shipped value is unchanged and is still guarded by # test/trinity/repo_config_test.exs, which reads it from the file rather than from this # override. + # + # Measured afterwards and recorded here rather than left implied: raising this did **not** stop + # the intermittent failure (run 35889922107 failed on the very commit that raised it, while the + # pull-request run on the same commit passed). SQLite returns SQLITE_BUSY immediately, without + # consulting the busy handler, when a connection holding a read transaction tries to upgrade to + # a write while another connection holds the write lock, because waiting there could deadlock. + # No timeout affects that path. The raise is kept because it does help ordinary lock waiting, + # but it is not the fix for what is recorded as R26 in docs/06-risk-register.md. config :trinity, Trinity.Repo, database: Path.expand("../trinity_test.db", __DIR__), pool: Ecto.Adapters.SQL.Sandbox, diff --git a/docs/06-risk-register.md b/docs/06-risk-register.md index 1051a5b..35f574e 100644 --- a/docs/06-risk-register.md +++ b/docs/06-risk-register.md @@ -25,3 +25,4 @@ | R24 | Fired 2026-09-20, in slice 013: neither precompiled `mdex_native` artifact loads in Burrito's musl ERTS (both need glibc's `libgcc_s`; `_dl_find_object: symbol not found`). Lifted the same day by the owner's decision: the linux package builds the NIF from source for musl with Zig as the linker (`scripts/zig-cc-musl`, config/config.exs, `rustler` at build time), measured on a fresh install of the packaged binary rendering the real provider's answer; the `--smoke` path now prints `TRINITY_SMOKE_MARKDOWN=ok` and exits 3 otherwise, and the `package` workflow reads that line on every target, so a dead NIF cannot pass again. Residual: macOS and Windows use the precompiled artifact, and the `package` workflow has yet to run green there since the slice/011 tag (an older failure at "Serves HTTP 200", now diagnosed by the step's log). The row above stands as written | none | none | none | none | 013 | | R23 | EMA / ID-JAG is beta everywhere (vendors label it so); IETF draft still moving | Low (re-scoped 2026-09-22) | Med | Spec revision breaks the exchange at the external authorization server, which is where it happens: Trinity redeems no assertion since the owner's decision of 2026-09-22 (slice 062 NOTES) | The resource server validates whatever audience-bound token that server issues, so a draft revision is that server's upgrade, not Trinity's; the row stays until the deferral lifts | 062 | | R25 | `glib` 0.18.5 carries GHSA-wrw7-89jp-8q8g (medium, unsoundness in `Iterator` and `DoubleEndedIterator` impls); first patched in 0.20.0 | Low | Med | The advisory is open against `src-tauri/Cargo.lock`; measured 2026-09-23 | It is transitive through the GTK 0.18 stack (`gtk` 0.18.2, `webkit2gtk` 2.0.2) that Tauri 2.11.5 requires on Linux, so it is fixed by a Tauri release built on gtk-rs 0.20 and not by a direct bump. **Scope:** the desktop shell only. The `headless` release is `steps: [:assemble]` with no Burrito and no `src-tauri`, so a server deployment does not ship this dependency. Owner: the maintainer. Lift condition: a Tauri release whose Linux stack resolves `glib` >= 0.20, adopted by the desktop shell work | 100 | +| R26 | An intermittent `Exqlite.Error: Database busy` fails the test suite on loaded CI runners, in a different test each time, always on a write to the main repository | Med | Low (test-only; no production path) | Failed the gate on pull requests 67, 70 and again on the commit that attempted to fix it; the same commit passed on its other run, so it is seed- and load-dependent. Not once reproduced locally across repeated runs | Ruled out: `busy_timeout`, raised from the shipped 5 s to 30 s in the suite, did not stop it. SQLite returns BUSY immediately, without the busy handler, when a read transaction upgrades to a write while another connection holds the write lock; no timeout affects that path. The suite runs two connections (one for the sandbox owner, one for Oban's boot check) and deliberately queues fifty concurrent writers at slice 070 AC7, which is the load that provokes it. Candidate fixes, not yet taken: drain the stress test's sessions before the suite continues; give that test its own tag and CI step; or remove the second connection's need. Owner: the maintainer. Lift condition: ten consecutive green CI runs of the full suite with the stress test in the default run | 070 |