Development documentation
Purpose
This guide contains the minimum local context needed for contributors. It keeps the branch changes small and aligned with the project reset scope.
Local runbook
Start the project with either:
-
Docker Compose:
docker compose -f docker-compose.dev.yml up --build(the development stack; the rootdocker-compose.ymlis the production release)docker compose -f docker-compose.dev.yml down -vto reset local data
The development stack is a Compose project of its own,
portfolixir-dev, with a database volume of its own (#932), so its reset never reaches the database of a production stack in the same checkout. The name is fixed, so a second checkout or git worktree on the same host shares that project and its volume: give it a project of its own with-p(for exampledocker compose -p portfolixir-dev-2 -f docker-compose.dev.yml up --build, and the same-pon itsdown -v), or the secondupreplaces the first checkout’s containers and its reset deletes the first checkout’s data. A development stack started before #932 runs under the checkout directory’s project instead and holds the ports: stop it once before the first start (docker compose -p <project> -f docker-compose.dev.yml down, wheredocker compose lsnames the project). Its database stays in that project’sportfolixir-postgres-datavolume, the one a production stack in this checkout would start on; “Moving off the development stack” in Home Deployment removes it. -
Phoenix from source, on Elixir 1.18 or newer (CI and both images pin 1.18.5 on Erlang/OTP 27):
mix.exsrefuses an older Elixir, because the suite’s async modules share ExUnit groups (group:), which exist from 1.18 on, and the code and the audit tooling need 1.17 (#1132). The database is PostgreSQL 15 or newer, with its contrib modules (btree_gist): the migrations useNULLS NOT DISTINCTand create thebtree_gistextension.mix deps.getmix ecto.setupmix phx.server
The development configuration reads
DATABASE_NAME,DATABASE_HOSTandDATABASE_PORT(defaultportfolixir_devon127.0.0.1:5432),PORT(default4000),PHX_HOST,PHX_BIND_ALL,PORTFOLIXIR_ALLOWED_HOSTSandPORTFOLIXIR_BACKGROUND_FETCH; prefix every command of a recipe with the same values. Logo discovery and the quote and FX sync run in development as they do in production;PORTFOLIXIR_BACKGROUND_FETCH=offleaves all three off, and every demo seed command inpriv/demo/README.mdsets it, so a seed run makes no outbound call. A release reads the same switch (Home Deployment). -
The test suite on a database of its own, for a second checkout or git worktree running it at the same time:
mix testneither creates nor migrates a database, so create and migrate it once, then run the suite with the same name.DATABASE_NAME=portfolixir_test_2 MIX_ENV=test mix ecto.create DATABASE_NAME=portfolixir_test_2 MIX_ENV=test mix ecto.migrate DATABASE_NAME=portfolixir_test_2 mix test
Required local checks
Run the list in AGENTS.md at the repository root (“Required Local Checks”)
before opening a PR. It is CI’s pre-commit, test
and quality jobs, so a branch that passes it locally passes them; it is not
copied here, because a copy is what fell behind before (#1022).
The npm checks need Node 24 (the Active LTS line). That version is
pinned in three places that must agree: actions/setup-node in CI,
engines.node in mcp-server/package.json, and the Node 24 base image in
mcp-server/Dockerfile (an exact tag pinned by digest) —
test/portfolixir/ci_test.exs asserts that they do, and that @types/node
describes the same major. Running the checks on another
major produces an EBADENGINE warning rather than an error; CI is the
authority.
If pre-commit is not installed:
pre-commit install --install-hooks
Measuring before activating a derived value
Which analytics run the :durable lifetime is a configuration decision
informed by measurement, never an architectural one
(ADR-0039 §2). The command
that produces the measurement seeds a deterministic synthetic ledger and times
every figure a surface waits on, twice, with the derived layer off:
DATABASE_NAME=portfolixir_bench mix ecto.create
DATABASE_NAME=portfolixir_bench mix ecto.migrate
DATABASE_NAME=portfolixir_bench mix portfolixir.derived.measure
It writes synthetic transactions, so point it at a throwaway database as
above — the same convention priv/demo uses — and it refuses to run in :prod.
--securities, --bookings and --years size the ledger; --skip-seed
measures whatever is already there.
An analytic that turns out cheap enough not to need a lifetime is a finding and is printed as one. Record the run in ADR-0039 next to the existing measurement rather than in a commit message, so the next activation decision has something to be compared against.
Upgrade migrations over legacy rows
Tests and CI migrate an empty database; a release migrates the database an
instance already holds, at boot. A migration that adds a CHECK, a NOT NULL, a
backfill, a UNIQUE index, a foreign key or an exclusion constraint to a table
that already exists can therefore pass every test and still stop an upgrade
on a row only an older instance has: a value the check refuses, a duplicate
the index refuses, an orphan the foreign key refuses. So every new migration
that adds one of the six needs a seeded case: a test that inserts, with
plain SQL, the rows an instance on an earlier release can hold, migrates to
head and asserts the migrated shape. An index built CONCURRENTLY or behind a
cleanup step is no exception, because the cleanup is what the case proves.
test/portfolixir/seeded_upgrade/rule_test.exs fails, naming the migration,
until the case exists.
A case uses Portfolixir.SeededUpgrade. from: is the version the scratch
database is migrated to before the seed (the last migration of the release
whose rows you seed), the seed runs in one transaction with a journal actor
set, and the tag names the migration the case covers:
@moduletag :seeded_upgrade
@tag seeded_upgrade: 20_261_002_120_000
test "the new check does not stop the upgrade over a legacy row" do
upgrade =
SeededUpgrade.upgrade!(
from: 20_260_926_121_500,
seed: fn db ->
%{rows: [[id]]} = SeededUpgrade.query!(db, "INSERT INTO ... RETURNING id", [])
id
end
)
assert %{rows: [[_value]]} =
SeededUpgrade.query!(upgrade, "SELECT ... WHERE id = $1", [upgrade.seeded])
end
Each case gets a scratch database of its own, dropped when the test exits,
so the cases run in the default mix test and, on their own, in CI’s
migration-roundtrip job (mix test --only seeded_upgrade). Prove a new
case once by putting the defect back locally and watching it fail;
test/portfolixir/seeded_upgrade/sprint16_test.exs holds the first two.
A migration that calls application code runs today’s code against the
schema of its own version. A column a later migration adds to a table that
code reads or writes (the journal’s audit_journal is the usual one) then
stops every upgrade from before that version, and every fresh install too if
the call runs on an empty database. So a new migration does not call
application code: write it with plain SQL, naming only the columns its
version has. A migration that calls it anyway needs one of two answers
(#1015):
- freeze it: the migration file itself is immutable (CI rejects any
change to it), so freeze the code it calls instead, in a module that names
only the columns of the migration’s version and no schema, as
Portfolixir.Tax.BuiltinSeeddoes for20260725140000with the statutory data, its columns and its journal write; or - pin it: keep the call and give the migration a seeded case over the
rows that reach the call, so the later schema change turns that case red
in the pull request that makes it. The policy-rule author backfill
(
20260926121500) is pinned this way, bysprint16_test.exs.
The five older migrations that call application code have their answer too
(#1042). The portfolio scope seed of 20260712130000 is frozen in
Portfolixir.Buckets.ScopeSeed: it read depots and cash accounts through
today’s schemas, and stopped an upgrade from 20260712120000 or earlier over
an instance holding either on column s0.former_names does not exist. The
other four are pinned by
test/portfolixir/seeded_upgrade/app_code_migrations_test.exs: the
former-name backfill (20260925150100), the tax identity normalisation
(20260925230000) and the two runs of the asset-class backfill
(20260523120000 and 20260608120000). The asset-class backfill is pinned,
not frozen, on purpose: the second run exists to apply newer inference rules,
so an upgrade writes the classes the inference of its own release gives.
Scope guardrails
- Use synthetic fixture data only.
- Keep tests free from external network calls.
- Keep financial values in Decimal-backed fields.
- Keep architecture decisions local and explicit to the three domain modules plus web.