bank: give it a server, and a scenario corpus over all 41 actions - #470
Conversation
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
f52db04 to
8756d9c
Compare
|
Rebased onto Bank takes no rung numberThe 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
The caveats now live in the docs
Gates
The remaining |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…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>
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 toexamples/rungs.txt, or renumbers anything.scripts/check_rung_filters.shpasses all 37 checks untouched, because bank stays invisible to it: it never callsmorph_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_serverexamples/bank/src/server/main.cpp:bank::db::setup(), a process-wideFileActionLog(the one bank's CLI already installs), aRemoteServerover a worker pool, and aQtWebSocketServerin front. ReadsBANK_DB/BANK_PORTand prints thelistening on ws://…linerun_scenarios.pyparses.Why
main()owns the server rather thanbank::app::App. Every rung puts itsRemoteServerin anApp. Bank cannot follow that without changing whatAppis: it is the client-side context the GUI and CLI build on — aBridgeover aLocalBackend, aMainThreadExecutorfor callbacks, and thelogin()/logout()pair that sets that bridge's session. It hosts noRemoteServerand 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 inmain.cppin full.Why the CMake target is written out locally rather than via
morph_add_rung(). Two reasons, the second decisive:morph::ladder_<rung>_lib, a target only the macro creates. Bank's library isbank_lib, built against a different dependency set (Lightweight/ODBC, and deliberately withoutapply_warnings()because the ORM headers are not-Werrorclean).examples/rungs.txtdocuments that "omitting a rung here whileexamples/<rung>/CMakeLists.txtcallsmorph_add_rung()is a hard configure error, by design". The call would therefore force bank intorungs.txt, and with it into ladder CI, the sanitizer and WASM matrices,codecov.ymlcomponents andcoverage.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 localadd_executablemirroring the macro's server block line for line.Bank is added to
SERVER_RUNGSandrun_scenarios.py'sRUNGSwithout touchingexamples/rungs.txt. That tuple has always actually selected on "has asrc/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:limsandcrmare rungs with no scenarios.Authentication is demo-grade, and commented as such. Bank's
AuthModelmints no bearer token —LoginRequestverifies a password and returns the principal for the client to install, which is exactly whatApp::login()does. There is no signed artefact for a server to verify, so aSigningAuthorizerwould refuse every client bank ships. The defaultallowAllAuthorizer()is not an option either, and this is the subtle part:RemoteServer::stampVerifiedPrincipalclearssession.principalwhenauthenticate()returnsnullopt, 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 aBankDemoAuthorizerthat 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
coverage_allowlist.jsonis 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:ListAccounts,ListPayees,ListPayments,ListLoans,ListBudgets,ListNotifications,MarkAllReadandGenerateStatementresolveaction.ownerthroughbank::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 viaMarkAllRead, write to it.SpendingByKindis worse: it takes a bareaccountId, 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.scenariois the inventory a fix has to cover.validate()shadows the model's ownValidationError. morph evaluates a DTO'svalidate()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.GenerateStatement'sclosingBalanceMinoris 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)
--twiceis what proves the corpus is re-runnable against a database it has already run on. Two files needed real fixes to get there:GenerateStatementfans 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
Every assertion in all 22 files was flipped one at a time (
ok↔err,==↔!=,~↔!~) 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:
intwith the enum named only in a doc comment — and there is noglz::metaanywhere underexamples/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).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.!~assertion is a regex over JSON text, compared with text.Files touched
examples/bank/(CMakeLists, README, newsrc/server/main.cpp) andscripts/scenario/(README,run_scenarios.py,scenario_coverage.py,test_morph_scenario.py, newscenarios/bank/). Not touched:examples/rungs.txt,codecov.yml,.github/,cmake/,examples/ledger/,docs/spec/. Thescripts/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 onscripts/.🤖 Generated with Claude Code
https://claude.ai/code/session_01C6uB9zxdSG8qp3VNAAyFvF