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
49 changes: 49 additions & 0 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,52 @@ jobs:
echo "::error::a commit in this range has no Signed-off-by line"; exit 1; }

- run: mix gate

# Slice 010 AC1 and AC2: the same migrations and the same suite on Postgres. A second job
# rather than a matrix entry so the required check keeps its context name, `gate`. This job
# is not required by the ruleset until it has run green on main once; then it is added.
# Tests tagged :sqlite read SQLite pragmas and are excluded here by tag, never by skip.
postgres:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: trinity
POSTGRES_PASSWORD: trinity
POSTGRES_DB: trinity_test
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U trinity"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
MIX_ENV: test
TRINITY_DB: postgres
DATABASE_URL: postgres://trinity:trinity@localhost:5432/trinity_test
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
with:
version-file: .tool-versions
version-type: strict

- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: |
deps
_build
key: ${{ runner.os }}-mix-postgres-${{ hashFiles('mix.lock') }}
restore-keys: ${{ runner.os }}-mix-postgres-

- run: mix deps.get

# The adapter is chosen at compile time (config/config.exs); this proves the build
# under TRINITY_DB=postgres links the Postgres adapter and nothing SQLite.
- name: The compiled adapter is Postgres
run: mix run -e 'Ecto.Adapters.Postgres = Trinity.Repo.__adapter__()'

- run: mix ecto.reset
- run: mix test --exclude sqlite
4 changes: 4 additions & 0 deletions .sobelow-skips
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
Config.CSP: Missing Content-Security-Policy,lib/trinity_web/router.ex:12,2DBC9F9
Traversal.FileModule: Directory Traversal in `File.mkdir_p`,lib/trinity/data_dir/lock.ex:57,35AB9EC
Traversal.FileModule: Directory Traversal in `File.read`,lib/trinity/data_dir/lock.ex:143,55D5ADB
Traversal.FileModule: Directory Traversal in `File.rm`,lib/trinity/data_dir/lock.ex:82,21D88E2
Traversal.FileModule: Directory Traversal in `File.write`,lib/trinity/data_dir/lock.ex:132,1C42814
4 changes: 4 additions & 0 deletions .sobelow-skips.reasons
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@
#
# format: <fingerprint><TAB><reason>
2DBC9F9 Missing CSP on the browser pipeline. The scaffold ships no Content-Security-Policy, and setting one needs LiveView nonce handling, which belongs with the UI rather than the toolchain. Slice 013 owns it. Skipped rather than fixed blind: a CSP written without the UI it protects is a guess.
35AB9EC Slice 010, Trinity.DataDir.Lock.acquire/2: the directory is the application's own data directory from config or Trinity.Paths, never a request or a model output; the lock has to create it before the Repo does. Low confidence traversal by the tool's own label.
55D5ADB Slice 010, Trinity.DataDir.Lock read/1: reads <data_dir>/LOCK, a path this module composes from the data directory and a constant file name; no caller supplies it.
21D88E2 Slice 010, Trinity.DataDir.Lock.release/2: removes <data_dir>/LOCK only when the file carries this process's own token; same composed path as the read.
1C42814 Slice 010, Trinity.DataDir.Lock take_over/2: rewrites <data_dir>/LOCK after the recorded holder's pid is found dead; same composed path, same constant name.
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ standards register names the rows that ask for them.
| 001 | Packaging spike: Burrito + ex_tauri smoke build | 0 Foundation | M | 000 | approved |
| 002 | Supply chain, early: SBOM, build provenance, the TLS floor | 0 Foundation | S | 000 | planned |
| 003 | FIPS build leg in CI, from source | 0 Foundation | M | 000 | planned |
| 010 | Core domain + persistence (Ecto/SQLite, schemas, Repo owner) | 1 Core loop | M | 000 | planned |
| 010 | Core domain + persistence (Ecto/SQLite, schemas, Repo owner) | 1 Core loop | M | 000 | done |
| 011 | LLM provider layer (req_llm behind `Trinity.LLM` behaviour) | 1 Core loop | M | 010 | planned |
| 012 | Session process + agent loop (gen_statem, DynamicSupervisor, rehydration) | 1 Core loop | L | 010, 011 | planned |
| 013 | LiveView chat UI with streaming | 1 Core loop | M | 012 | planned |
Expand Down
3 changes: 2 additions & 1 deletion VERSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ never pin a version hex marks as retired or vulnerable.
| `bandit` | ~> 1.5 | ✅ in `mix.lock` | HTTP server. |
| `ecto_sql` | ~> 3.13 | ✅ in `mix.lock` | |
| `ecto_sqlite3` | >= 0.0.0 | ✅ in `mix.lock` | Primary DB. FTS5 available. |
| `postgrex + pgvector` | optional, ~> 0.3 | 🔍 not a single package | Secondary DB path. Not in default deps; behind `TRINITY_DB=postgres`. Two packages, so no single lock key. |
| `postgrex` | >= 0.0.0 (optional) | ✅ in `mix.lock` | Secondary DB driver, `optional: true` so the desktop build carries none of it; compiled in only under `TRINITY_DB=postgres`, which the CI job proves. Added at Slice 010. Was one row with pgvector; pgvector keeps its own row below. |
| `pgvector` | optional, ~> 0.3 | 🔍 not yet a dependency | Vectors on the Postgres path. Not yet a dependency; Slice 032 decides. Split from the postgrex row at Slice 010. |
| `oban` | ~> 2.24 | 🔍 not yet a dependency | Uses `Oban.Engines.Lite` on SQLite. ⚠️ Oban Pro Workflows/Smart engine are Postgres-only. Added at Slice 050. |
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. |
| `req_llm` | ~> 1.22 | 🔍 not yet a dependency | Provider layer (streaming, tools, structured output, usage). ⚠️ The pin was `~> 1.10` against a recorded latest of 1.10.0; the real latest was twelve minors ahead. Check event shapes against the current version at Slice 011, not against this file's prose. Added at Slice 011. |
Expand Down
31 changes: 31 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,37 @@ config :trinity,
ecto_repos: [Trinity.Repo],
generators: [timestamp_type: :utc_datetime]

# Slice 010. The database adapter is chosen at compile time: SQLite is primary and the default,
# Postgres is the CI-tested alternative behind TRINITY_DB=postgres (docs/adr/0002). An Ecto
# adapter is fixed in `use Ecto.Repo`, so switching means recompiling, and this file says so
# rather than pretending a runtime variable could do it.
config :trinity,
:db_adapter,
(case System.get_env("TRINITY_DB", "sqlite") do
"sqlite" -> Ecto.Adapters.SQLite3
"postgres" -> Ecto.Adapters.Postgres
other -> raise "TRINITY_DB must be sqlite or postgres, got #{inspect(other)}"
end)

# Slice 010, every environment, SQLite only (the Postgres adapter ignores keys it does not
# know, and the CI matrix proves that). One writer: the pool has exactly one connection, so the
# single-writer rule SQLite imposes is the pool's shape rather than a hope. Each pragma is named
# here rather than inherited from the adapter's default, so a default change upstream is a diff
# here and not a silent behaviour change. `synchronous: :normal` under WAL can lose the last
# transaction on power loss and cannot corrupt; the receipts file (slice 024, its own Repo
# below) decides its own setting. `wal_auto_check_point` is in pages; the 010 stress test
# reports the -wal size after its run so the value can be set from a measurement.
if System.get_env("TRINITY_DB", "sqlite") == "sqlite" do
config :trinity, Trinity.Repo,
pool_size: 1,
journal_mode: :wal,
synchronous: :normal,
foreign_keys: :on,
busy_timeout: 5_000,
cache_size: -64_000,
wal_auto_check_point: 1_000
end

# Configure the endpoint
config :trinity, TrinityWeb.Endpoint,
url: [host: "localhost"],
Expand Down
1 change: 0 additions & 1 deletion config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import Config
# Configure your database
config :trinity, Trinity.Repo,
database: Path.expand("../trinity_dev.db", __DIR__),
pool_size: 5,
stacktrace: true,
show_sensitive_data_on_connection_error: true

Expand Down
13 changes: 10 additions & 3 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,16 @@ if config_env() == :prod do
# any business writing to.
database_path = System.get_env("DATABASE_PATH") || Trinity.Paths.database_path()

config :trinity, Trinity.Repo,
database: database_path,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
# Slice 010: the pool size is 1 by config/config.exs and is not read from the environment
# here, because a larger pool on SQLite is a second writer waiting on busy_timeout, not
# capacity. A Postgres build sets its own size below.
config :trinity, Trinity.Repo, database: database_path

if System.get_env("TRINITY_DB") == "postgres" do
config :trinity, Trinity.Repo,
url: System.get_env("DATABASE_URL") || raise("TRINITY_DB=postgres needs DATABASE_URL"),
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
end

# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
Expand Down
24 changes: 20 additions & 4 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,26 @@ import Config
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :trinity, Trinity.Repo,
database: Path.expand("../trinity_test.db", __DIR__),
pool_size: 5,
pool: Ecto.Adapters.SQL.Sandbox
# Slice 010: the data-dir lock takes a temporary directory in tests, so a test run never
# contends with a running Trinity on the same machine, and two test runs at once do contend,
# which is the property under test.
config :trinity, Trinity.DataDir.Lock,
dir:
Path.join(System.tmp_dir!(), "trinity-test-lock-#{System.get_env("MIX_TEST_PARTITION", "0")}")

# Slice 010: the pool has one connection (config/config.exs), so the sandbox hands every test
# the same connection and concurrent writers in a test queue on it exactly as they do in
# production. Tests that touch the Repo are not `async: true` for that reason.
if System.get_env("TRINITY_DB") == "postgres" do
config :trinity, Trinity.Repo,
url: System.get_env("DATABASE_URL") || raise("TRINITY_DB=postgres needs DATABASE_URL"),
pool_size: 10,
pool: Ecto.Adapters.SQL.Sandbox
else
config :trinity, Trinity.Repo,
database: Path.expand("../trinity_test.db", __DIR__),
pool: Ecto.Adapters.SQL.Sandbox
end

# We don't run a server during test. If one is required,
# you can enable the server option below.
Expand Down
1 change: 1 addition & 0 deletions coverage.tsv
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
slice_id percent sha date
000 27.01 e935c7b 2026-09-06
001 30.37 5a9c8f7 2026-09-06
010 44.88 45ba4f0 2026-09-20
2 changes: 1 addition & 1 deletion docs/05-data-model.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 05: Data model

All tables have `id` (UUIDv7 as binary_id, sortable), `inserted_at`, `updated_at` (utc_datetime_usec).
All tables have `id` (UUIDv7 as binary_id, sortable, minted by `Trinity.UUID` since slice 010), `inserted_at`, `updated_at` (utc_datetime_usec).
SQLite is primary; every migration must also run on Postgres in the CI matrix. Use Ecto types that map on both
(`:binary_id`, `:map` → JSON text on SQLite, `:utc_datetime_usec`). Vector columns and FTS tables are created
with adapter-specific `execute/1` guarded by `repo().__adapter__()`.
Expand Down
9 changes: 7 additions & 2 deletions lib/trinity.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
defmodule Trinity do
# DataCase and NetworkGuard live in test/support, which is compiled only in :test, so the
# export list is environment-dependent. TrinityWeb.ConnCase crosses the boundary to reach
# DataCase, and the live-tagged tests reach NetworkGuard.
# DataCase, and the live-tagged tests reach NetworkGuard. Slice 010 exports Paths (the
# application locks the data directory before the Repo starts), Repo and UUID (the contexts
# use them), and the Sessions sub-boundary: a context TrinityWeb may call (docs/01), which
# exports only its API module and keeps Store and the schemas inside.
use Boundary,
deps: [],
exports: if(Mix.env() == :test, do: [DataCase, NetworkGuard], else: [])
exports:
[Paths, Repo, UUID, Sessions] ++
if(Mix.env() == :test, do: [DataCase, NetworkGuard, Factory], else: [])

@moduledoc """
Trinity keeps the contexts that define your domain
Expand Down
19 changes: 19 additions & 0 deletions lib/trinity/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ defmodule Trinity.Application do
desktop_children() ++
[
TrinityWeb.Telemetry,
# Slice 010: one node per data directory. Before the Repo, so a refused boot has
# opened no database file; the reason names the holder's OS pid and mode.
{Trinity.DataDir.Lock, dir: lock_dir(), mode: mode()},
Trinity.Repo,
{Ecto.Migrator,
repos: Application.fetch_env!(:trinity, :ecto_repos), skip: skip_migrations?()},
Expand Down Expand Up @@ -63,6 +66,22 @@ defmodule Trinity.Application do
@spec desktop_children() :: [module()]
def desktop_children, do: @desktop_children

# The lock lives in the data directory the database defaults to. A deployment that points
# DATABASE_PATH elsewhere still locks the data directory, which is the thing two instances
# would otherwise share; the test environment points it at a temporary directory.
defp lock_dir do
Application.get_env(:trinity, Trinity.DataDir.Lock, [])[:dir] ||
Trinity.Paths.ensure_data_dir()
end

# `desktop` unless the process says otherwise; slice 061's headless release sets it.
defp mode do
case System.get_env("TRINITY_MODE", "desktop") do
"headless" -> :headless
_ -> :desktop
end
end

defp skip_migrations? do
# By default, sqlite migrations are run when using a release
System.get_env("RELEASE_NAME") == nil
Expand Down
Loading
Loading