ledger: let a client create the book everything else keys off - #384
Merged
Conversation
Codecov Report❌ Patch coverage is
📢 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
force-pushed
the
fix/361-create-ledger
branch
from
September 1, 2026 15:43
a0632b8 to
2ed51ec
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #361.
ledger was the only rung shipping a server whose root aggregate could not be created over the wire.
OpenAccountrefused withOpenAccount: no such ledger, every other action was keyed on aledgerIdthat had to already exist, andsrc/server/main.cppseeded nothing — so a freshly startedladder_ledger_serverserved a book nobody could open. The rung's own tests seeded the row through theDataMapper, which is not available to anything on the other side of a socket.Reproduced first, against a live server on a fresh
LEDGER_DB:The shape
CreateLedger { name }→CreateLedgerResult { id }onLedgerModel, registered keyless — it is the one action on that model carrying noledgerId, so there is nothing forBRIDGE_KEY_FROMto key on.The precedent followed is
polls::PollModel/CreatePoll: a model keyed by its aggregate id whose creating action is keyless. Deliberately not kanban'sProjectAdminModel, 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 toLedgerRecord::name'sSqlAnsiString<128>by astatic_assert, the same guard and for the same reason as kanban'skMaxProjectNameBytes. Success and refusal both journal through the existinglogAction/logFailurepair.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.scenarionames 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
ledgersrows inrun_scenarios.pystay, as a fixture convenience for the 14 sibling files pinned to ids 1 and 2. What changes isseed()'s doc comment, which asserted as fact thatledgersrows "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
ladder_ledger_testsAll tests passed (685 assertions in 135 test cases)— 7 newCreateLedgercases, 26 assertionsrun_scenarios.py --rung ledger --twicemutate_scenario.pyon the new file31 mutants, 31 caught, 0 survivedtest_morph_scenario.pyRan 118 tests … OKscenario_coverage.py18/18 dispatched,workflows 16/16,total actions dispatched: 72/72check_spec_citations.shSpecs 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 aCHANGELOG.mdentry under### Added.Not verified:
ladder_ledger_testscannot be built withMORPH_ENABLE_STRICT_COMPILATION=ONfrom 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 -Werrorinladder_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.pynever 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