Skip to content

ledger: let a client create the book everything else keys off - #384

Merged
Yaraslaut merged 1 commit into
masterfrom
fix/361-create-ledger
Sep 1, 2026
Merged

ledger: let a client create the book everything else keys off#384
Yaraslaut merged 1 commit into
masterfrom
fix/361-create-ledger

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Closes #361.

ledger was the only rung shipping a server whose root aggregate could not be created over the wire. OpenAccount refused with OpenAccount: no such ledger, every other action was keyed on a ledgerId that had to already exist, and src/server/main.cpp seeded nothing — so a freshly started ladder_ledger_server served a book nobody could open. The rung's own tests seeded the row through the DataMapper, which is not available to anything on the other side of a socket.

Reproduced first, against a live server on a fresh LEDGER_DB:

ok   step 4 [books] session principal=$who token=$token
FAIL step 5: do OpenAccount ledgerId=1 name="Cash" kind=0 currency=1
  reply: err message="OpenAccount: no such ledger"

The shape

CreateLedger { name }CreateLedgerResult { id } on LedgerModel, registered keyless — it is the one action on that model carrying no ledgerId, so there is nothing for BRIDGE_KEY_FROM to key on.

The precedent followed is polls::PollModel/CreatePoll: a model keyed by its aggregate id whose creating action is keyless. Deliberately not kanban's ProjectAdminModel, which is a separate model because it has per-project RBAC to administer; ledger has none, so splitting a model out would buy nothing.

The name bound in validate() is tied to LedgerRecord::name's SqlAnsiString<128> by a static_assert, the same guard and for the same reason as kanban's kMaxProjectNameBytes. Success and refusal both journal through the existing logAction/logFailure pair.

Authorization: any authenticated principal may create a book, and no owner is recorded — because there is no per-book role table to record one in. That is a real gap rather than a design, and it is filed as #382 rather than half-built here.

The harness

The new scenarios/ledger/bootstrap-a-book-over-the-wire.scenario names no seeded id at all — it creates its book, opens accounts in it, and works from there, which is the journey this issue said no client could perform.

The two seeded ledgers rows in run_scenarios.py stay, as a fixture convenience for the 14 sibling files pinned to ids 1 and 2. What changes is seed()'s doc comment, which asserted as fact that ledgers rows "are created by no registered action" — true when written, false as of this PR, and exactly the kind of stale prose the drift guard exists for.

Coverage pins move deliberately: the action universe 71 → 72, the ledger workflow floor 15 → 16.

Verification

Command Result
ladder_ledger_tests All tests passed (685 assertions in 135 test cases) — 7 new CreateLedger cases, 26 assertions
run_scenarios.py --rung ledger --twice exit 0, 16 files green on both passes against one database
mutate_scenario.py on the new file 31 mutants, 31 caught, 0 survived
test_morph_scenario.py Ran 118 tests … OK
scenario_coverage.py exit 0 — ledger 18/18 dispatched, workflows 16/16, total actions dispatched: 72/72
check_spec_citations.sh exit 0

Specs updated rather than left to contradict the code: the ledger rung-5 design §1, the corpus-expansion inventory (15 → 16 journeys), scripts/scenario/README.md, examples/ledger/README.md, and a CHANGELOG.md entry under ### Added.

Not verified: ladder_ledger_tests cannot be built with MORPH_ENABLE_STRICT_COMPILATION=ON from a worktree nested under the main checkout (#372, pre-existing and unrelated), so the test run above comes from a non-strict build dir. The model and DTO themselves compile clean under -Weverything -Werror in ladder_ledger_lib. The other four rungs' scenarios, the framework's own C++ suite and a Doxygen build were not run.

Filed rather than folded in: #382 (any authenticated principal can read and write any book — reproduced over the wire: bob reads and writes a book alice created) and #383 (mutate_scenario.py never flips != back to ==, so all 13 != assertions in the corpus go operator-unmutated — reproduced while mutating the new scenario).

🤖 Generated with Claude Code

https://claude.ai/code/session_01GnSnsebJ92Kti1D7m3RwYJ

@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.44444% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
examples/ledger/src/models/ledger_model.cpp 94.11% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

ledger was the one rung shipping a server whose root aggregate no
registered action could produce. `ledgers` rows existed only because a
unit test or the scenario harness inserted them with SQL, so a freshly
started ladder_ledger_server against a new LEDGER_DB served a book nobody
could open: OpenAccount refuses with "OpenAccount: no such ledger", and
every other action on all three models keys on a ledgerId that must
already exist. Reproduced before fixing -- Login mints a valid token, and
the next step fails:

  ok   step 4 [books] session principal=$who token=$token
  FAIL step 5: do OpenAccount ledgerId=1 name="Cash" kind=0 currency=1
    reply: err message="OpenAccount: no such ledger"

CreateLedger { name } answers { id } and is registered on LedgerModel
keyless -- it is the one action there carrying no ledgerId, being what
mints one, so ActionKeyTraits' hasKey = false default is the honest
answer. That is polls::PollModel's shape for CreatePoll on a model
otherwise keyed by pollId, not kanban's: CreateProject lives on a separate
ProjectAdminModel because kanban has per-project RBAC to administer, and
this rung has none. Any authenticated principal may create a book; a
tokenless caller is refused "unauthorized" by LedgerAuthorizer before the
model is entered, and an empty principal by the design spec §11 gate every
mutating action here already carries. No owner is recorded, because there
is no per-book role table to record one in and half an authorization
scheme reads as a guarantee the rung cannot keep -- filed as morph#382,
which reproduces a second principal reading and writing a book the first
one created.

The name is bounded to LedgerRecord::name's own SqlAnsiString<128>
capacity, tied to it by a static_assert: Light::SqlFixedString's
constructor is noexcept and truncates rather than throwing, so a drifting
bound means answering "ok" about a book stored under a name the caller
never sent. Same guard, same reason, as kanban's kMaxProjectNameBytes.

The harness keeps seeding ledgers 1 and 2. That is now a fixture
convenience for the fourteen sibling files written against those fixed
ids, not a statement that a book cannot be created over the wire, and
seed()'s doc comment says so instead of the reverse. The new
bootstrap-a-book-over-the-wire.scenario names no seeded id at all and
would pass against a database the driver never touched, which is what
demonstrates the gap is closed. scenario_coverage.py's action pin moves
71 -> 72 and ledger's workflow floor 15 -> 16, both deliberately.

Verified: 685 assertions in 135 ledger test cases pass (the seven new
CreateLedger cases among them); run_scenarios.py --rung ledger --twice
passes 16 files in both passes against one database; mutate_scenario.py
on the new file reports 31 mutants, 31 caught, 0 survived;
test_morph_scenario.py 118 tests OK and scenario_coverage.py exits 0 with
ledger at 18/18 actions and 16/16 workflows.

Not verified: ladder_ledger_tests cannot be built with
MORPH_ENABLE_STRICT_COMPILATION=ON in a worktree under .claude/worktrees/
(-Wshadow-header on AUTOMOC's relative include, already filed as morph#372
and morph#376), so the test run above is from a non-strict build dir. The
model and DTO themselves do compile clean under -Weverything -Werror in
ladder_ledger_lib.

Closes #361.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnSnsebJ92Kti1D7m3RwYJ
@Yaraslaut
Yaraslaut force-pushed the fix/361-create-ledger branch from a0632b8 to 2ed51ec Compare September 1, 2026 15:43
@Yaraslaut
Yaraslaut merged commit b219ce5 into master Sep 1, 2026
25 of 31 checks passed
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.

ledger has no CreateLedger action, so no out-of-process client can bootstrap it

1 participant