Skip to content

bank: give it a server, and a scenario corpus over all 41 actions - #470

Merged
Yaraslaut merged 2 commits into
masterfrom
feat/87-bank-scenarios
Sep 7, 2026
Merged

bank: give it a server, and a scenario corpus over all 41 actions#470
Yaraslaut merged 2 commits into
masterfrom
feat/87-bank-scenarios

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Bank had the largest model surface in the tree and no way to drive it as a real client: eleven models, 41 registered actions, reachable only in process. This gives it a server binary and a scenario corpus that exercises every one of those actions over a WebSocket.

Scope, and what this deliberately does not do

Refs #87 — this does not close it. Issue #87's remaining open question is step 1, bank's slot in the ladder, which its own comments reserve for the maintainer. Nothing here numbers bank, adds it to examples/rungs.txt, or renumbers anything. scripts/check_rung_filters.sh passes all 37 checks untouched, because bank stays invisible to it: it never calls morph_add_rung().

Steps 2–5 of #87's original 2024 plan are addressed by its own later comments (step 2 dissolved when #86 closed deciding nothing would land for bank's GUI to migrate onto; bank's hand-rolled controllers are the recorded, justified pattern). This PR takes the one piece that was blocked on nothing: bank's action surface being undrivable from outside its process.

ladder_bank_server

examples/bank/src/server/main.cpp: bank::db::setup(), a process-wide FileActionLog (the one bank's CLI already installs), a RemoteServer over a worker pool, and a QtWebSocketServer in front. Reads BANK_DB / BANK_PORT and prints the listening on ws://… line run_scenarios.py parses.

Why main() owns the server rather than bank::app::App. Every rung puts its RemoteServer in an App. Bank cannot follow that without changing what App is: it is the client-side context the GUI and CLI build on — a Bridge over a LocalBackend, a MainThreadExecutor for callbacks, and the login()/logout() pair that sets that bridge's session. It hosts no RemoteServer and needs none; adding one would put a listener's machinery into every desktop and CLI process to serve a fourth consumer. The server side bank needs is small enough to live in main.cpp in full.

Why the CMake target is written out locally rather than via morph_add_rung(). Two reasons, the second decisive:

  1. The macro links morph::ladder_<rung>_lib, a target only the macro creates. Bank's library is bank_lib, built against a different dependency set (Lightweight/ODBC, and deliberately without apply_warnings() because the ORM headers are not -Werror clean).
  2. Calling the macro was not available. examples/rungs.txt documents that "omitting a rung here while examples/<rung>/CMakeLists.txt calls morph_add_rung() is a hard configure error, by design". The call would therefore force bank into rungs.txt, and with it into ladder CI, the sanitizer and WASM matrices, codecov.yml components and coverage.sh — the numbering decision Fold examples/bank into the application ladder's conventions (scoping only, blocked on #86) #87 reserves. So the target is a local add_executable mirroring the macro's server block line for line.

Bank is added to SERVER_RUNGS and run_scenarios.py's RUNGS without touching examples/rungs.txt. That tuple has always actually selected on "has a src/server/main.cpp" — the property deciding whether a scenario can reach the thing. Being a rung and having scenarios were already independent in the other direction: lims and crm are rungs with no scenarios.

Authentication is demo-grade, and commented as such. Bank's AuthModel mints no bearer token — LoginRequest verifies a password and returns the principal for the client to install, which is exactly what App::login() does. There is no signed artefact for a server to verify, so a SigningAuthorizer would refuse every client bank ships. The default allowAllAuthorizer() is not an option either, and this is the subtle part: RemoteServer::stampVerifiedPrincipal clears session.principal when authenticate() returns nullopt, so under the default authorizer every bank action would run with an empty principal and fail with "no session principal" — bank would be registered, reachable, and unable to do anything. So the server installs a BankDemoAuthorizer that vouches for a non-empty asserted principal and refuses an empty one. Per-row ownership is still enforced by the models (db::loadOwned), so cross-customer isolation is genuinely testable; credential proof is not, because bank has none to prove.

The corpus — 22 files, 41/41 actions

before after
bank actions dispatched — (no server) 41/41, 0 allowlist exemptions
bank workflow files 22/22 qualifying (floor set to 22)
corpus-wide action total 72/72 113/113

coverage_allowlist.json is unchanged — bank needed no exemptions. Every action is reachable and reached.

Three things the corpus records because they are true, not because they are right

Each is asserted with expect ok, deliberately, so the day it changes something fails:

  1. Owner-by-name is not checked against the session. ListAccounts, ListPayees, ListPayments, ListLoans, ListBudgets, ListNotifications, MarkAllRead and GenerateStatement resolve action.owner through bank::resolveOwner, which returns the caller's string verbatim and only falls back to the session principal when it is empty. Nothing compares the two, so any signed-in customer can read another's data by typing their username — and via MarkAllRead, write to it. SpendingByKind is worse: it takes a bare accountId, consults no owner at all, and answers even an anonymous caller. Actions addressed by row id do enforce ownership, and the two spellings sit side by side on the same models. an-owner-named-outright-is-not-checked-against-the-session.scenario is the inventory a fix has to cover.
  2. validate() shadows the model's own ValidationError. morph evaluates a DTO's validate() before dispatch, so a client sees "action failed validation: <Model>/<Action>" and never the model's message. Roughly half of bank's hand-written validation messages are unreachable over the wire.
  3. GenerateStatement's closingBalanceMinor is the account's current balance, not its balance as at the window's end — narrowing the window moves the credits and debits and leaves it alone.

Verification (actual output)

$ python3 scripts/scenario/run_scenarios.py --rung bank --build-dir build/bank-srv --twice
every scenario passed in: bank
EXIT=0            # 44 file runs = 22 files x 2 passes

$ python3 scripts/scenario/scenario_coverage.py
  bank       actions 41/41 dispatched (0 exempt), workflows 22/22
  total actions dispatched: 113/113
Every registered action is dispatched and every rung meets its floor.
exit=0

$ python3 scripts/scenario/test_morph_scenario.py
Ran 132 tests in 0.467s
OK

$ sh scripts/check_rung_filters.sh
All 37 rung-filter checks passed.

$ python3 scripts/scenario/run_scenarios.py --rung pastebin --build-dir .../build/ladder-srv
every scenario passed in: pastebin      # no regression from the shared-file edits

--twice is what proves the corpus is re-runnable against a database it has already run on. Two files needed real fixes to get there: GenerateStatement fans out over every account its owner has ever opened, so its totals grow each pass — those assertions are now pinned to captured account ids instead.

Mutation testing — necessary, not sufficient

$ python3 scripts/scenario/run_scenarios.py --rung bank --build-dir build/bank-srv --mutate
  ok   a-card-through-its-whole-lifecycle.scenario (+ mutants)
  ... all 22 files ...
every scenario passed in: bank
EXIT=0

Every assertion in all 22 files was flipped one at a time (okerr, ==!=, ~!~) and rerun: zero surviving mutants, so no assertion in the corpus is inert.

Stated as the weak check it is. morph#460 established that mutation cannot detect the vacuous-comparison class — it perturbs the scenario, not the server, and both mutants of a dead assertion die anyway. So that class was checked separately, by hand:

  • Bank's DTOs declare no enum-class members at all — all the enum-ish fields are plain int with the enum named only in a doc comment — and there is no glz::meta anywhere under examples/bank/. This re-confirms the morph#445 measurement against current master, and it is why bare integers are the correct encoding here (unlike ledger, whose corpus is red for exactly this reason under morph#460).
  • Grepping the corpus for field <name> (==|!=) <integer>: there is no !=-against-an-integer assertion anywhere. Every enum assertion is a passing == against an integer, which can only pass if the wire value really is that integer — the tautology morph#460 found is not expressible here.
  • Every !~ assertion is a regex over JSON text, compared with text.

Files touched

examples/bank/ (CMakeLists, README, new src/server/main.cpp) and scripts/scenario/ (README, run_scenarios.py, scenario_coverage.py, test_morph_scenario.py, new scenarios/bank/). Not touched: examples/rungs.txt, codecov.yml, .github/, cmake/, examples/ledger/, docs/spec/. The scripts/scenario/ edits are additive (one dict entry, one tuple entry, one floor, one pinned name-set) and overlap in directory only with the open PRs on scripts/.

🤖 Generated with Claude Code

https://claude.ai/code/session_01C6uB9zxdSG8qp3VNAAyFvF

Yaraslaut and others added 2 commits September 6, 2026 22:29
Bank had the largest model surface in the tree and no way to drive it as a
real client: eleven models and 41 registered actions, reachable only in
process. This adds the server binary and 22 scenario files that exercise
every one of those actions over a WebSocket.

`examples/bank/src/server/main.cpp` is a standalone `ladder_bank_server`:
`bank::db::setup()`, a process-wide `FileActionLog` (the one bank's CLI
already installs), a `RemoteServer` over a worker pool, and a
`QtWebSocketServer` in front. It reads `BANK_DB`/`BANK_PORT` and prints the
`listening on ws://...` line `run_scenarios.py` parses. `main()` owns the
server rather than `bank::app::App` because that class is the *client* side
context the GUI and CLI build on -- a bridge over a LocalBackend and the
login/logout pair -- and giving it a RemoteServer would put a listener into
every desktop process to serve one consumer.

The CMake target is written out locally rather than obtained by calling
`morph_add_rung()`. The macro links `morph::ladder_<rung>_lib`, which only it
creates, and -- decisively -- `examples/rungs.txt` documents that calling the
macro without being listed there is a hard configure error. That would force
bank into ladder CI, the sanitizer and WASM matrices, codecov components and
coverage.sh, which is the numbering decision #87 reserves for the maintainer.
`scripts/check_rung_filters.sh` still passes untouched: bank is invisible to
it precisely because it never calls the macro.

Bank is added to `SERVER_RUNGS` and `run_scenarios.py`'s `RUNGS` without
touching `examples/rungs.txt`. That tuple has always selected on "has a
src/server/main.cpp" -- the property that decides whether a scenario can
reach a thing -- and being a rung and having scenarios were already
independent in the other direction, since `lims` and `crm` are rungs with no
scenarios.

The server installs an authorizer that vouches for a non-empty asserted
principal. This is demo-grade and is commented as such: bank's `AuthModel`
mints no bearer token, so there is no signed artefact to verify, and morph's
default `allowAllAuthorizer()` does not authenticate -- `RemoteServer` then
*clears* the principal, leaving every bank action failing with "no session
principal". Per-row ownership is still enforced by the models, so
cross-customer isolation is genuinely testable; credential proof is not,
because bank has none.

Coverage: 41/41 bank actions dispatched, 0 allowlist exemptions, 22/22 files
qualifying as workflows. Corpus-wide the action total goes 72 -> 113.

Three things the corpus records because they are true, not because they are
right, and each would otherwise change unnoticed:

  * Actions resolving an owner *by name* (`ListAccounts`, `ListPayees`,
    `ListPayments`, `ListLoans`, `ListBudgets`, `ListNotifications`,
    `MarkAllRead`, `GenerateStatement`) never compare that name with the
    session principal, so any signed-in customer can read -- and via
    `MarkAllRead`, write -- another's data by typing their username.
    `SpendingByKind` consults no owner at all and answers an anonymous
    caller. Actions addressed by row id do enforce ownership.
  * Every DTO carrying a `validate()` predicate has its model's matching
    `ValidationError` shadowed: morph refuses first, with
    "action failed validation: <Model>/<Action>", so those model messages are
    unreachable over the wire.
  * `GenerateStatement`'s `closingBalanceMinor` is the account's current
    balance, not its balance as at the window's end.

Verified: `run_scenarios.py --rung bank --twice` passes (44 file runs, exit
0), `scenario_coverage.py` exits 0, `test_morph_scenario.py` runs 132 tests
OK, and `check_rung_filters.sh` passes all 37 checks.

Refs #87. The one question #87 still leaves open is step 1, bank's slot in
the ladder: nothing here numbers it, adds it to `examples/rungs.txt`, or
renumbers anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6uB9zxdSG8qp3VNAAyFvF
… not do

PR #470 gave bank a server and a 22-file scenario corpus without touching
`examples/rungs.txt`, which left a reader finding a `ladder_bank_server` and
`scripts/scenario/scenarios/bank/` while bank is absent from the file that
calls itself the single authority. These docs say why, in the places that
absence is visible from.

Bank takes no rung number. It predates the ladder, `LADDER.md` already cites
it as prior art and measures rung effort in "bank-equivalents", and the
numbers in that table are load-bearing for the rungs that consume each other's
answers -- so bank stays unnumbered prior art rather than forcing a
renumbering. `examples/rungs.txt` now explains its own omission: bank ships a
server built by a plain `add_executable()` and never calls `morph_add_rung()`,
so the hard configure error the file documents never fires for it, and adding
the name would not describe bank but enrol it in `ci.yml`'s ladder jobs,
`wasm-ladder.yml`, `coverage.sh` and `codecov.yml`. `LADDER.md` records the
same status beside its numbered table, with the conventions bank does and does
not follow. Nothing is renumbered and no list gains an entry.

`scripts/scenario/README.md` explains why `bank` is in `run_scenarios.py`'s
`RUNGS` and `scenario_coverage.py`'s `SERVER_RUNGS` while absent from
`rungs.txt`: those lists answer different questions, and the one here has
always selected on "ships a src/server/main.cpp". Its `WORKFLOW_FLOORS`
listing is brought up to what the code holds (`ledger` 16, `bank` 22).

The caveats PR #470 found are now in the docs rather than only in its body.
`examples/bank/README.md` carries them: the server authenticates nobody -- it
vouches for whatever principal a client asserts, because bank's `AuthModel`
mints no token to verify -- so it must not be read as an authentication
example, though per-row ownership is enforced and cross-customer isolation is
genuinely testable. Beside it, the three defects the corpus pins as `expect
ok`: morph#471 (`resolveOwner()` prefers the caller-supplied owner over the
session principal, so ten actions across eight models serve another customer's
data and `MarkAllRead` writes to it), the fifteen actions whose DTO
`validate()` shadows the model's own `ValidationError` message, and
`GenerateStatement`'s `closingBalanceMinor` reporting a live balance. morph#471
is cited from the two scenario files that pin it, so a passing run is not read
as an endorsement.

Comments in `examples/bank/CMakeLists.txt`, `run_scenarios.py`,
`scenario_coverage.py` and `test_morph_scenario.py` that said the numbering
question was reserved for the maintainer now say what the answer is. No
behaviour changes: every edit outside the four documents is a comment.

Verified: `ladder_rungs.sh` and `ladder_rungs.sh ci-path-regex` produce output
byte-identical to before, `rungs.txt` is still pure ASCII, `check_rung_filters.sh`
passes all 37 checks, `check_spec_citations.sh` passes, `scenario_coverage.py`
exits 0, `test_morph_scenario.py` runs 132 tests OK, and
`run_scenarios.py --rung bank --twice` passes all 44 file runs against a
freshly built `ladder_bank_server`.

Refs #87, #471.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6uB9zxdSG8qp3VNAAyFvF
@Yaraslaut
Yaraslaut force-pushed the feat/87-bank-scenarios branch from f52db04 to 8756d9c Compare September 6, 2026 20:47
@Yaraslaut

Copy link
Copy Markdown
Member Author

Rebased onto master (the only conflict was test_morph_scenario.py's corpus-wide action total, now 114ledger gained ListTransactions in #472/#428 while this branch was open) and added a documentation pass. No behaviour changes: every edit outside the four documents is a comment.

Bank takes no rung number

The one question #87 left open (its step 1) is now answered in the tree rather than only in a PR body: bank is unnumbered prior art and gets no slot in the numbered table. Nothing is renumbered, and neither examples/rungs.txt nor LADDER.md's table gains an entry.

  • examples/rungs.txt now explains its own omission, in its existing dense-comment style and still pure ASCII: bank predates the ladder, carries no rung number, ships its server via a plain add_executable() and never calls morph_add_rung() — so the hard configure error the file documents never fires for it — and "Adding the name here would not describe bank; it would enrol bank in every consumer above — ci.yml's ladder-tests and ladder-sanitizers, wasm-ladder.yml's build loop, coverage.sh and codecov.yml's per-rung components — which is a scope decision rather than a naming one."
  • examples/LADDER.md records the same beside the table: "bank is not on that table and has no rung number. … it stays unnumbered prior art rather than taking a slot: numbering it would mean renumbering everything from rung 4 down, and the numbers here are load-bearing" — plus what bank does have (11 models, 41 actions, a ladder_bank_server, a 22-file corpus dispatching all 41) and which of this document's conventions it does not follow. The convention half of Fold examples/bank into the application ladder's conventions (scoping only, blocked on #86) #87 stays open; only the numbering half is settled.

The caveats now live in the docs

examples/bank/README.md gains a "Bank and the application ladder" section, #### Scenarios (with run_scenarios.py --rung bank --build-dir <dir>), and two prominent warnings:

  • "This server authenticates nobody — do not copy it as an authentication example." It vouches for whatever principal a client asserts, because bank's AuthModel mints no token to verify. Per-row ownership is enforced, so cross-customer isolation is real and testable; credential proof is not.
  • "Defects the scenario corpus pins on purpose"#471 (resolveOwner() prefers the caller-supplied owner over the session principal; ten actions across eight models, MarkAllRead writing), the fifteen actions whose DTO validate() shadows the model's own ValidationError message, and GenerateStatement's closingBalanceMinor reporting a live balance. bank: resolveOwner() prefers the caller-supplied owner over the session principal, so 10 actions serve another customer's data #471 is also cited from the two scenario files that pin it, so a passing run reads as a record and not an endorsement.

scripts/scenario/README.md explains why bank is in RUNGS/SERVER_RUNGS while absent from rungs.txt — those lists answer different questions, and the tuples here have always selected on "ships a src/server/main.cpp" — so the next reader does not "fix" the apparent inconsistency. Its WORKFLOW_FLOORS listing is brought up to what the code holds (ledger 16, bank 22).

Gates

Check Result
ladder_rungs.sh / ladder_rungs.sh ci-path-regex output byte-identical to before the change
examples/rungs.txt non-ASCII bytes 0
check_rung_filters.sh all 37 checks pass
check_spec_citations.sh pass
scenario_coverage.py exit 0 — bank 41/41 actions, 22/22 workflows; 114/114 corpus-wide
test_morph_scenario.py 132 tests, OK
run_scenarios.py --rung bank --twice 44/44 file runs pass against a freshly built ladder_bank_server

The remaining scripts/check_*.sh gates need a coverage/moc build directory and were not run.

@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut
Yaraslaut merged commit 1d690d5 into master Sep 7, 2026
44 checks passed
Yaraslaut added a commit that referenced this pull request Sep 7, 2026
…expect it (#475)

Master went red on the merge of #470 (bank's server and 22-scenario corpus)
into #473's pre-flight, exactly as #473's own comment said it would:

  ::error::scripts/scenario/scenarios/bank/ is a corpus for 'bank', which is
  not listed in examples/rungs.txt -- this job configures no ladder_bank_server
  for it. Either list it there, or build its server in this job and extend this
  check to expect it.

That is the gate working. A corpus with no server in this job would otherwise
have run five of six directories and reported green, which is morph#462's
defect one level down. So this takes the second branch the error names rather
than weakening the check or adding bank to examples/rungs.txt -- the latter
would enrol bank in wasm-ladder.yml's build loop, coverage.sh and codecov.yml's
per-rung components, which is a scope decision that rungs.txt and
examples/LADDER.md have already settled the other way.

ladder-tests now configures -DMORPH_BUILD_BANK_EXAMPLE=ON. That option alone
guards add_subdirectory(examples/bank) in the root CMakeLists.txt;
examples/bank/CMakeLists.txt then declares ladder_bank_server inside its own
if(MORPH_BUILD_QT), which this job already sets. Nothing new to install: the
apt step already names Lightweight's libsqlite3-dev, libyaml-cpp-dev,
libzip-dev, unixodbc-dev and libsqliteodbc (MORPH_BUILD_LADDER=ON fetches the
same pinned ORM through examples/common) and libgl1-mesa-dev, and the server
links only Qt6::Core and Qt6::WebSockets, both already installed. The Build
step names the target after the all-target build -- a no-op when the flag is
present, and "no rule to make target" when someone drops it, instead of a
silent absence surfacing as the scenario step's exit 2 a Qt build later.

The pre-flight gains a third arm, not a relaxation: a corpus that is neither a
listed rung nor served by an add_executable(ladder_<name>_server) in
examples/<name>/CMakeLists.txt still fails. The arm is keyed on that
declaration rather than on the literal name "bank", so deleting the target
makes the check fire again -- verified by commenting the add_executable() out
and watching bank go red -- and a scratch scenarios/nonesuch/ still fails.

scripts/scenario/README.md's "In CI" section said CI does not run the corpus
because no workflow builds the servers. That has been false since #473;
rewritten to describe the pre-flight, the whole-corpus run, and the
scripts/scenario/ entry in the job's path filter.

Verified locally with the job's own flags: ladder_bank_server builds, all six
servers exist, `run_scenarios.py` over bank+bookmarks+kanban+ledger+pastebin+
polls reports "every scenario passed", ctest -L ladder -LE stress is 1026/1026,
scripts/check_rung_filters.sh is 39/39, and `ladder_rungs.sh ci-path-regex` is
byte-identical (neither of its inputs is touched).


Claude-Session: https://claude.ai/code/session_01C6uB9zxdSG8qp3VNAAyFvF

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant