ledger: rung 5 of the application ladder (Tasks 1-16 of 26) - #132
Open
Yaraslaut wants to merge 52 commits into
Open
ledger: rung 5 of the application ladder (Tasks 1-16 of 26)#132Yaraslaut wants to merge 52 commits into
Yaraslaut wants to merge 52 commits into
Conversation
Cherry-picked (framework files only) from the unmerged kanban rung-4 branch (ladder-kanban-impl, commit 5c4d577), ahead of PR #121 landing, because ledger (rung 5) needs this field to implement its rule-cascade journaling per kanban's design spec decision (docs/superpowers/specs/2026-08-16-kanban-rung4-design.md, section 9): journal cascades with a causal parent-id, suppress rule evaluation during replay. The kanban app-code half of that commit (its own divergence test) is not part of this branch and lands separately when #121 merges. - LogEntry::causalParentId (include/morph/journal/action_log.hpp): a new additive/defaulted std::string field, empty by default ("no parent" sentinel, mirroring idempotencyKey's shape). Set by application code journaling a cascaded mutation to the triggering entry's own stable, app-minted identity -- explicitly NOT LogEntry::seq, which is sink-local and re-stamped on every forward (docs/spec/journal/journal.md's own Invariants section), so it cannot serve as a cross-sink/cross-restart causal key. Round-trips through toJson/fromJson; a legacy line missing the key decodes with the empty default per the existing leniency contract; does not bump kLogFormatVersion (additive, not breaking). - morph::journal::isReplaying() (include/morph/journal/journal.hpp): a thread-local replay-mode signal mirroring morph::session::current()'s exact shape (detail::tlsIsReplaying() + RAII detail::ScopedReplayFlag). replay() installs the guard around its dispatch loop, so isReplaying() reads true for every entry it dispatches and false again once replay() returns -- restored via RAII regardless of how the loop exits. Additive: no existing replay()/Model::execute call site needed to change. - docs/spec/journal/journal.md: new "Causal links and replay-mode signaling" section (with a Contents entry) documenting both additions in full, plus updates to the LogEntry field table, API reference, Design decisions, Invariants, and Cross-references sections. - Tests: tests/test_action_log.cpp gains framework-level coverage (causalParentId's default/round-trip/legacy-decode behavior; isReplaying() false outside replay(), true only for replay()'s own dispatch loop and observable from inside a replayed Model::execute). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ger's README
Verified against include/morph/util/rational.hpp's own doc comment,
docs/spec/util/rational.md, docs/spec/util/quantity_type.md, and
tests/test_quantity.cpp (lines asserting DecimalPlaces{0} round-trips):
DecimalPlaces has no floor of 1. Quantity<U, 0> is a legal, tested,
first-class configuration -- zero-decimal currencies (JPY/KRW) are
natively representable with no app-side convention or x-rules gate.
This was a stale claim in both the round-5 forms-gaps summary
(LADDER.md) and ledger's own "Expected strain points" section,
discovered while grounding the rung-5 design spec in the actual
framework API rather than repeating the README's draft claims
verbatim.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Resolves examples/ledger/README.md's open design questions in writing,
per LADDER.md's discipline rule, covering steps 1-7 of the build order
plus the step-8 sync-philosophy write-up (produced alongside, no new
model code required):
- The per-currency zero-sum invariant, defined precisely (legs sum to
zero within each currency; foreign-amount pairs balance across, never
entering the zero-sum check itself).
- Multi-currency: currency as an account property, not a per-transaction
choice; exact Rational exchange rates; the corrected DecimalPlaces{0}
fact for JPY/KRW (see the companion docs fix in the prior commit).
- Budget aggregation strategy (in-model summation, not SQL) and why the
sanctioned-escape-tier doesn't apply here.
- Rules: reuses kanban's cascade-journaling decision verbatim (cited from
its unmerged design spec), with rule-version pinning as the additional
money-grade requirement layered on top, not a competing option.
- The one framework dependency this branch carries ahead of PR #121
(causalParentId/isReplaying, cherry-picked framework-only).
- Undo as a compensating action, and why undoLast() is disqualified.
- The Rational overflow fuzz test and the pre-decode validation gap,
each routed to a named finding rather than an app-side workaround.
- CSV/OFX import: bookmarks' op-id ledger pattern reused verbatim, plus
a distinct content-hash dedup layer for cross-import duplicate
detection.
- Reports: submit->poll shape, WAL-read-transaction snapshot semantics
(the pre-cleared escape-tier case), and UTC-storage vs. local-month
boundary handling.
- The sync-philosophy benchmark's three scenarios and the explicit
"server arrival order, full stop" statement.
- Empty-principal refusal at the model, per LADDER.md's binding
cross-rung convention.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… invariant hook Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sertion (absorbed from rung 3)
BackendRig has no clientCount()/nClients() accessor (brief's Step 4
assumption was wrong, not just misnamed -- verified against
backend_rig.hpp's actual public interface and every existing call site,
none of which reads a count back from the rig). ClientPool's constructor
takes nClients as an explicit parameter instead, matching what every
caller already has on hand from its own BackendRig{mode, nClients, ...}
call.
bridge(i) and executor() are confirmed correct as the brief assumed.
Adds test_client_pool.cpp (not in the brief) to exercise ClientPool
against a real BackendRig across all three modes, since the brief's
Step 1 test only covers convergence.hpp.
…t deps)
Discovered while writing the implementation plan: Tasks 17/23/24 depend
on examples/common/testkit/{action_driver,offline_rig,client_pool,
convergence}.hpp, which TESTING.md's ownership table says predate rung 5
but which, like causalParentId, only exist on the unmerged
ladder-kanban-impl branch as of this writing. Each introducing commit
(ad491c4, 66717e7, 3630a15) was verified scoped strictly to
examples/common/testkit/ + examples/common/CMakeLists.txt, ships its own
test file, and has no kanban app-code entanglement -- cherry-picked
alongside the original causalParentId commit, same rationale, same
verification standard (builds, own tests pass: 7 test cases / 48
assertions, all green).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
27 tasks (Task 0 = already-applied cherry-picks, Tasks 1-25 = TDD implementation, Task 26 = deferred post-merge rebase), covering the design spec's steps 1-7 plus the step-8 sync-benchmark write-up, backend and GUI together (unlike rung 4's backend/GUI split): - Tasks 1-5: scaffolding, strong ids/errors, Currency unit system, schema, entities. - Tasks 6-9: account/transaction DTOs, LedgerModel skeleton, StoreTransaction's per-currency zero-sum invariant, foreign-amount pairs. - Task 10-11: BudgetModel, empty-principal refusal. - Task 12: RuleModel + cascade-journaling with causalParentId and rule-version pinning, including the named divergence test. - Task 13: Rational overflow fuzz test + two named framework findings. - Task 14-16: undo as a compensating action, CSV import with dedup, reports' submit->poll job idiom with WAL-snapshot semantics. - Task 17: local-time month boundary handling + offline-stack test. - Tasks 18-22: presenters/bridges for ledger/budget/rules, the ReportJobPoller (a new poll-one-job-to-terminal-state idiom, distinct from EventPoller's open-ended stream shape), QML views. - Tasks 23-25: multi-client stress test, sync-benchmark write-up + Scenario A/B/clock-skew tests, coverage gate + reconciliation. Self-review pass: filled in every "follow Task N's structure" reference with real inline code (no bare cross-references left, per the no-placeholders rule), fixed step renumbering after expansion, verified spec-section coverage (all 12 design-spec sections map to a task). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds ledger::db::configure/applyMigrations/setup (matching bank::db's three-function split) and every LIGHTWEIGHT_SQL_MIGRATION for the rung's 11 tables: ledgers, accounts, transaction_journals, transaction_legs, categories, budgets, budget_limits, rules, ledger_imported_ops, ledger_imported_txn_hashes, ledger_report_jobs. Also opts ladder_ledger_lib out of the local fastcache-cc compiler-cache launcher: it was observed serving a stale, empty object for src/db/schema.cpp.obj regardless of the file's actual content (reproduced with fastcache-cc invoked directly, in both direct and FASTCACHE_NO_DIRECT=1 modes -- same poisoned key either way; only changing the object's output path produced a fresh key). This is a local machine-cache bug unrelated to morph's own build; disabling the launcher for this one target is the minimal, reversible workaround. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found during SDD execution, before dispatching Task 4: the original Task 4 text specified a parameterless `ledger::db::setup()` called directly from a test -- this contradicted the established convention. bank/polls both declare `setup(const std::string& connectionString)`, and polls::db::database.hpp's own doc comment states outright "tests never call this" -- the real test-time pattern is morph::ladder::testkit::DbFixture, which configures its own connection and applies migrations independently. Also replaced the migration DDL's prose bullet-point description with real, verified code: cross-checked every Lightweight::SqlMigration method against bank/bookmarks/pastebin's actual schema.cpp files (RequiredForeignKey/ForeignKey + SqlForeignKeyReferenceDefinition, Column vs RequiredColumn for nullability, CreateUniqueIndex as a separate plan call, NVarchar(0) as the unbounded-text convention). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found during pre-dispatch verification: Task 5's test still called ledger::db::setup() directly (the same error already fixed out of Task 4), and its entity file was left with a "follow the same shape" placeholder for 9 of 11 entities instead of complete code. Replaced with the full ledger_entity.hpp (all 11 entities) and a DataMapper-based schema test, both verified against real, already- compiling code: Field<std::optional<T>, ...> for plain nullable columns (confirmed via Lightweight/DataBinder/StdOptional.hpp's SqlDataBinder<std::optional<T>> specialization), BelongsTo<> assignment (`accountRow.ledger = ledgerRow;`) and Query<T>().Where(...).All() copied verbatim from examples/polls/tests/test_polls_schema.cpp's real usage. One field (ReportJobRecord::resultJson, nullable + unbounded) has no existing precedent to copy verbatim -- flagged explicitly for the implementer to build-verify rather than trust as given. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds ledger_entity.hpp with one Light::Field<>-wrapped struct per table (LedgerRecord, AccountRecord, TransactionJournalRecord, TransactionLegRecord, CategoryRecord, BudgetRecord, BudgetLimitRecord, RuleRecord, ImportedOpRecord, ImportedTxnHashRecord, ReportJobRecord), plus the schema test's AccountRecord round-trip extension. Deviations from the brief: - Header include: the brief's <Lightweight/DataMapper/BelongsTo.hpp> + <Lightweight/DataMapper/Field.hpp> pair does not transitively provide Light::SqlAnsiString/SqlRealName/PrimaryKey, confirmed by a real compile failure. Switched to <Lightweight/DataMapper/DataMapper.hpp>, matching bookmarks::db::ImportedOpRecord's real, already-compiling include. - examples/ledger/CMakeLists.txt: extended the existing fastcache-cc stale-object workaround (previously only ladder_ledger_lib) to also cover ladder_ledger_tests, which hit the same bug -- a rebuild after editing test_ledger_schema.cpp kept linking a stale object missing the new TEST_CASE, verified via strings/--list-tests, not just a passing ctest run. ReportJobRecord::resultJson's Light::Field<std::optional< Light::SqlMaxDynamicAnsiString>, ...> composition (nullable + unbounded, flagged by the brief as having no direct precedent) compiled and round-tripped as written -- no adjustment needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mpty-string sentinel) Task 5's reviewer caught a Task-4-authored comment inconsistency: the migration comment described causal_parent_id as an empty-string sentinel, but the actual DDL declares it nullable and Task 5's entity wraps it as std::optional<SqlAnsiString<64>> -- the comment, not the behavior, was wrong. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…onal, not Quantity<Currency::USD,2>) Found and resolved before dispatch, per design spec §2's own answer and Task 3's Money<C> precedent: Quantity<Unit,dp>'s Unit parameter is a concrete enumerator value, not the enum type, so AccountInfo cannot hold a single Quantity generic over an account's actual currency. Fixed to a plain morph::math::Rational field alongside the sibling currency field. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found while pre-verifying Task 7: the same setup()-called-directly-in-a- test error (already caught and fixed in Tasks 4/5's own text) recurred 13 more times across Tasks 7, 8 (x2), 10, 11, 12, 14, 15 (x2), 16, and 23 -- drafted before the pattern was first caught, never swept back through the rest of the plan. Replaced every occurrence with morph::ladder::testkit::DbFixture fixture; and added the missing #include "testkit/db_fixture.hpp" to every freshly-created test file that was still missing it (test_budget_model.cpp, test_rule_model.cpp, test_ledger_import.cpp, test_ledger_reports.cpp, test_multiclient.cpp). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…MODEL_KEY vs BRIDGE_KEY_FROM)
Found while pre-verifying: the plan assumed a keyed model takes its key
as a constructor argument (LedgerModel model{LedgerId{1}}) and that
BRIDGE_KEY_FROM applies to every keyed action including the first. Both
wrong, verified against polls::PollModel's real shape: a keyed model is
plain default-constructible (PollModel model;, no key argument); the key
routes shared instances at the Bridge/registry layer, read fresh from
each action's own field. BRIDGE_MODEL_KEY is used exactly once (the
model's first keyed action, which also establishes ModelKeyTraits<M>);
every other action sharing the key type uses BRIDGE_KEY_FROM instead.
Also fixed: the OpenAccount execute() body was fabricating a stub
LedgerRecord for a BelongsTo assignment instead of loading the real
persisted parent row (BelongsTo assignment needs an actually-queried
record, per polls::db::OptionRecord's own usage); added the missing
ledger/core/errors.hpp include ledger_model.cpp actually needs for
ValidationError/NotFound; noted that ledger provisioning (no CreateLedger
action in scope) means the test seeds its own ledgers row.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…y-per-model caveat)
Same class of pre-existing plan defects: Tasks 8-10's tests either called
the wrong constructor pattern or hardcoded LedgerId{1} without seeding a
ledgers row first (fixed via SQLite's per-test autoincrement reset,
confirmed via DbFixture's drop-and-remigrate behavior); Task 8's
StoreTransaction implementation was left as prose with no code; Task 9's
foreign-amount test was an unbalanceable 2-leg sketch in comments; Task
10's BudgetModel had no DTOs/model code at all and a test that was pure
prose. All replaced with real, verified code. Task 10 also surfaces a
new open question (flagged, not resolved): whether BudgetModel's mixed
per-action key types (LedgerId vs BudgetId vs Account/CategoryId pairs)
can use BRIDGE_MODEL_KEY/BRIDGE_KEY_FROM the way LedgerModel's uniform
keying does, or whether it must run unkeyed -- left for the implementer
to resolve against morph::model::ModelKeyTraits's real requirements.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Adds transaction_dto.hpp's TransactionLeg (StoreTransaction lands in Task 8), and LedgerModel implementing execute(OpenAccount)/ execute(GetLedger), registered via BRIDGE_REGISTER_MODEL/ACTION. - OpenAccount's key is wired by hand-written ModelKeyTraits/ ActionKeyTraits rather than BRIDGE_MODEL_KEY/BRIDGE_KEY_FROM: those macros route through keyToString<K>, constrained to std::integral/ std::string by morph::model::ModelKey, and LedgerId wraps std::optional<std::int64_t> (like every LEDGER_DEFINE_STRONG_ID type), so it does not satisfy that concept. PrimaryKey is declared std::int64_t and key() unwraps LedgerId's payload directly -- the plain, non-macro customisation point model_key.hpp's own doc comments anticipate. - execute(OpenAccount) returns the newly created AccountInfo, not void: ActionTraits<A>::Result deduces via decltype(execute(...)), and the registry runner unconditionally does `auto result = model.execute(action);`, which cannot bind void. Matches bank::CustomerModel::execute(const OpenAccount&)'s own dto::AccountInfo return. - ledger_model.hpp now includes <morph/core/bridge.hpp>, required by BRIDGE_REGISTER_ACTION's own documented hard requirement (registerActionExecutorOnce is only defined there) -- same precedent as polls::PollModel's header. - types.hpp gains a glz::meta<T> specialisation for every LEDGER_DEFINE_STRONG_ID type (LedgerId, AccountId, JournalId, CategoryId, BudgetId, RuleId, ReportJobId), matching bookmarks::BookmarkId's wire-codec shape -- without it, any DTO carrying one of these fields fails deep inside glaze's to/from templates the first time BRIDGE_REGISTER_ACTION tries to serialise it, which this task's OpenAccount/GetLedger registration is the first to trigger. - units.hpp adds currencyToCode/codeToCurrency: header-only constexpr, matching bank::currencyCode's identical shape (a pure switch over a small enum) rather than bank::format()'s .cpp split, which exists only because that function does non-trivial work (std::format, arithmetic) -- a different complexity class from a bare switch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e precision)
Two Important findings from independent review of Task 7's LedgerModel:
1. test_ledger_model.cpp discarded OpenAccount's AccountInfo return value
entirely, only verifying creation indirectly via a follow-up GetLedger
call. That return exists specifically because the framework's Result
deduction can't register a void execute() -- add a direct CHECK on
created.id.hasValue() to close the coverage gap on that field.
2. AccountInfo::balance's placeholder zero was hardcoded to
DecimalPlaces{2} in both execute(OpenAccount) and execute(GetLedger),
regardless of the account's actual currency. This rung exists to
exercise both dp=2 (USD/EUR) and dp=0 (JPY/KRW) currencies (per
units.hpp's own doc comment), so a freshly opened JPY/KRW account was
reporting its zero balance tagged at the wrong precision. Derive
DecimalPlaces from UnitTraits<Currency>::meta(currency).defaultDecimals
in both places instead -- the same customization point units.hpp
defines. The real balance computation (summing legs) remains out of
scope for this task; Task 8 now inherits a correctly precision-tagged
zero baseline instead of a wrong one.
Verified with a full build + the complete ladder_ledger_tests suite
(36 assertions, 14 test cases, all passing).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… execute() return-type propagation)
Found while pre-verifying: (1) SqlTransaction's guessed constructor
(single-arg) was wrong -- real shape is SqlTransaction{connection,
SqlTransactionMode::ROLLBACK}, verified against bank::LoanModel's own
multi-row commit. (2) DateTime::toEpochMillis() does not exist -- real
conversion is (*timestamp.value).value.time_since_epoch().count(),
verified against bookmarks::db's own nowMs()/fromEpochMs() helpers.
(3) Documented the ladder-wide morph::ladder::now() injectable-clock
convention (examples/common/clock.hpp, LADDER.md framework prerequisite
3) for future tasks with server-stamped timestamps (Tasks 15/16) --
confirmed StoreTransaction's own client-supplied date field is correctly
exempt from that convention. Also propagated Task 10's void-execute()
fixes (LinkAccountToCategory/SetBudgetLimit now return ids, per Task 7's
verified execute()-cannot-return-void discovery) and added the
AccountRecord.category schema addition LinkAccountToCategory needs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… API Confirmed AlterTable()/AddNotRequiredForeignKeyColumn() directly against Lightweight/SqlQuery/Migrate.hpp -- the exact method for a nullable FK column via ALTER TABLE, replacing the plain AddColumn() guess (which would have needed a separate AddForeignKey call and wasn't verified as correct). Reuses Task 4's own categoriesRef() helper. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… stays intact Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…Principal test helper) Found while pre-verifying: morph::session::Context::principal is a plain std::string (empty means unauthenticated), never std::optional/.hasValue() as the plan guessed. The real accessor is morph::session::current() returning const Context* (nullptr outside any dispatch). The real test-time mechanism to drive an empty-principal scenario is morph::session::detail::ScopedContext, following bookmarks::tests::test_bookmark_model.cpp's own real ScopedPrincipal helper pattern (contextFor()+ScopedContext RAII) verbatim rather than reinventing it. Also added the missing BudgetModel-side test (the brief named BudgetModel in scope but only had a LedgerModel test) and extended the fix instruction to cover every mutating execute() overload on both models, not just StoreTransaction. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tion
Adds CreateCategory/LinkAccountToCategory/CreateBudget/SetBudgetLimit/
GetBudgetReport actions on a new BudgetModel, following the plain
default-constructible + hand-written ModelKeyTraits/ActionKeyTraits
pattern Task 7 established.
Schema: AccountRecord gains a nullable category_id FK, added via the
first ALTER TABLE migration in this codebase
(AlterTable("accounts").AddNotRequiredForeignKeyColumn(...)).
CategoryRecord's definition moves ahead of AccountRecord in
ledger_entity.hpp since BelongsTo<&CategoryRecord::id, ...> requires a
complete type, not just a forward declaration.
GetBudgetReport's spent computation joins in code (never a raw SQL
SUM() over the Rational columns, per design spec §3): accounts linked
to the budget's category, journals whose date falls in the requested
UTC month (parsed from "YYYY-MM"), then legs matching both id sets via
WhereIn, summed with Rational::operator+ in a loop.
LinkAccountToCategory gets no ActionKeyTraits specialization: the
primary template's hasKey = false is already the correct default for
an action with two co-equal ids and no single natural key.
…ledger_id scope, unvalidated month Addresses three Important findings from independent review of BudgetModel::execute(const GetBudgetReport&) (Task 10, 31f267a): 1. The date-range filter on the journal query was untested -- both StoreTransaction calls in the existing test landed inside the query month, so nothing would catch a regression that dropped the date-range predicate entirely. Added a third, out-of-month (February 2026) StoreTransaction against the same Groceries account; the existing spent == 7550 assertion now only holds if the out-of-month leg is correctly excluded. 2. The journal-collecting query filtered only by date range, not by the budget's own ledger -- collecting every journal across every ledger in the database for that month before building an unbounded WhereIn list. Added .Where(FieldNameOf<&db::TransactionJournalRecord::ledger>, "=", ledgerId) using the budget's own ledger, already available from the existing budgetRows.front() lookup. 3. monthRangeMs discarded std::from_chars's return status and never checked the parsed month was in [1, 12] or that the resulting year_month_day was ok() -- a malformed month like "2026-13" silently produced a ~255-day range instead of an error. Strengthened SetBudgetLimit::validate()/GetBudgetReport::validate() in budget_dto.hpp with a new detail::isValidYearMonth helper (digit positions, literal '-' at index 4, month in [1, 12]), matching this rung's existing validate()-at-the-DTO-boundary convention. Also hardened monthRangeMs itself to check from_chars's ec and year_month_day::ok(), throwing ValidationError, as defense in depth for any caller that bypasses validate(). Added a test asserting GetBudgetReport/SetBudgetLimit both throw ValidationError given month = "2026-13". Full ladder_ledger_tests suite: 50 assertions in 19 test cases, all passing (up from 48/18 before this fix). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…astructure Discovered while pre-verifying Task 12: cascade-journaling requires LedgerModel to append a manually-constructed LogEntry with causalParentId set, which is only possible if the model already journals its own triggering actions. A plain-constructed model (every test in this plan uses LedgerModel model; with no constructor argument, per Task 7's own established pattern) is never wrapped by the framework's registry IModelHolder, so the automatic per-call journaling never fires for it -- confirmed against kanban's real, already- implemented attachActionLog/logAction pair (unmerged ladder-kanban-impl branch), which this task retrofits verbatim into LedgerModel and BudgetModel before Task 12 needs to build the cascade on top of it. No behavior change for any existing test (none attach a log, so logAction stays a no-op exactly as before). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tachActionLog/logAction, retrofit for Task 12) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… Task 12 (RuleModel/cascade) Pre-verifying Task 12's divergence test surfaced a real gap: StoreTransaction is a pure insert, so morph::journal::replay() would double-insert it (unlike kanban's naturally-idempotent MoveTaskPosition). Inserted Task 11b, copying kanban's real, verified opId + applied-ops-ledger pattern (execute(MoveTaskPosition)'s lookup-before-mutate, write-after-commit shape) onto StoreTransaction. Also fully corrected Task 12 itself, which had three real defects: (1) RuleModel's constructor and principal check both used the plan's pre-Task-7/ pre-Task-11 stale patterns; (2) the causal-parent-id minting mechanism was left as 'resolve the exact mechanism' instead of specified -- now copied verbatim from kanban's real evaluateRules (mint from a real DB row's own autoincrement id, e.g. TransactionJournalRecord's, never LogEntry::seq; call the cascade's *implementation* directly, bypassing any public execute() overload, which would double-log); (3) SetCategory needs BRIDGE_REGISTER_ACTION and a public execute() overload even though no client is expected to dispatch it directly, because morph::journal::replay()'s dispatcher requires the action type to be registered regardless of who created the entry -- verified against kanban's own ApplyTagMutation, which is registered for exactly this reason. Also resolved the two 'which account/which category' design questions concretely instead of leaving them speculative, and wrote out the real divergence test (previously all comments), including the replay read-back API (IModelHolder::into<Model>(), copied from kanban's own real divergence test) that this plan had not independently verified before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…replay-safety prerequisite for Task 12) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ersion pinning Adds RuleModel (CreateRule/UpdateRule) with the same attachActionLog/ logAction self-journaling shape as LedgerModel/BudgetModel. RuleModel is plain default-constructible and keyed by LedgerId via hand-written ModelKeyTraits/ActionKeyTraits (LedgerId fails the ModelKey concept, same as every other keyed model in this rung); UpdateRule is deliberately left keyless (carries ruleId, not ledgerId -- same shape as LinkAccountToCategory). Wires rule evaluation into LedgerModel::execute(StoreTransaction): after the journal+legs commit loop but still inside the same SqlTransaction, a RuleTrigger::DescriptionContains match cascades into a SetCategory mutation (links the transaction's first Expense/Revenue leg to the rule's named category). The category is looked up, never auto-created -- a rule naming a nonexistent category silently doesn't fire. The cascade's LogEntry carries causalParentId minted from TransactionJournalRecord's own row id (never LogEntry::seq, which is sink-local and not stable across restarts/forwarding) and its payload carries ruleId/ruleVersion, pinning which rule version fired so a later edit to the rule never changes what replay() reproduces. SetCategory gets both a public, directly-dispatchable execute() overload (needed only so replay()'s dispatcher lookup can route a recorded "SetCategory" entry -- not because a client dispatches it that way) and a shared setCategoryImpl(mapper, action) the cascade path calls directly, bypassing the public overload to avoid double-logging. setCategoryImpl takes the DataMapper by reference so the cascade's mutation commits atomically with the triggering transaction rather than through a second connection. Rule evaluation is gated on !morph::journal::isReplaying(): replaying a StoreTransaction entry must stay a pure no-op for rule purposes, since the cascade it originally produced is already its own separate recorded entry later in the same log. Cascade LogEntry emission is deferred until after the trigger's own logAction call so the trigger always precedes its cascade in seq order. Tests: rule CRUD (version bump on update), a cascade test asserting causalParentId != LogEntry::seq and payload contains ruleId/ruleVersion, and a divergence test proving replay after editing a rule still reproduces the original (v1) cascade outcome, never the edited (v2) one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…esign spec §7) - tests/test_ledger_rational_fuzz.cpp: fuzz test measuring int64 overflow boundary when summing 10^9-unit Rational legs - docs/findings/001-rational-checked-arithmetic-mode.md: no checked-arithmetic mode in Rational operators; overflow can occur silently at ~9B row sum - docs/findings/002-rational-no-predecode-validation-seam.md: Rational::setWire clamps hostile den==0 to den==1 instead of rejecting; bypass for pre-decode validation - examples/ledger/tests/test_ledger_model.cpp: test verifying clamped legs are caught by zero-sum invariant, not by explicit validation Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…xact overflow boundary
C1: the pre-decode-gap test now genuinely decodes {"num":5,"den":0,"dp":2}
through glz::read_json (reaching Rational::setWire via the glz::meta<Rational>
wire-codec specialisation) rather than the plain in-process 3-arg constructor,
so it actually exercises finding #2's claim that the wire-decode path
clamps hostile input rather than rejecting it.
C2: the overflow-boundary fuzz test now finds the exact boundary
(9,223,372,037 rows) via a binary search over real Rational::operator+
calls (O(log N) ~33 additions) instead of a naive count<N walk (O(N) ~9.2
billion additions, several minutes at -O2 -- verified directly: an
implementer's first attempt at raising the loop cap to reach the true
boundary via brute force ran for 6+ minutes without finishing and was
killed). Each binary-search step still calls the real Rational::operator+
(via exponentiation-by-squaring over a running 'doubling' term, not a
literal N-object loop) and cross-checks it against a closed-form int64
oracle, so the measurement stays empirical (the type's actual arithmetic
decides the outcome) while running in well under a second. Finding #1
now cites the exact measured boundary instead of a hand-computed estimate
the original test could not have produced.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Scoped re-review of the C1/C2 fix (commit c5994b5) found a new Critical bug in sumOfNLegs's doubling helper: 'term = term + term' ran unconditionally every loop iteration, including the final one where its result is never consumed (n has already reached 0 after the shift). For any candidate mid with bit-length >= 34 -- which includes the binary search's own top-of-range probes and the true measured boundary itself -- that trailing double computes perLeg * 2^34, overflowing int64_t: real undefined behavior, not the type's own documented overflow-at-the-boundary being measured. Fixed by breaking out of the loop immediately once n reaches 0 after the shift, before the next double -- every term this function now computes is one a candidate actually consumes. Re-verified: same exact boundary (9,223,372,037), same ~0.2s runtime, full morph_tests suite still 1077/1077 (20139 assertions). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… fuzz test Second scoped re-review found the sibling bug to the one just fixed in 4eebe69: sumOfNLegs was still called unconditionally on every binary- search probe, including probes the oracle had already determined would overflow (wouldOverflow == true, roughly half the ~33 probes while bracketing the boundary from above). Even though the result was discarded on that side, sumOfNLegs's own 'result = result + term' accumulation still ran Rational::operator+= on values already known to exceed int64_t's range -- real UB, one step earlier than the boundary this test claims to safely observe. Fixed by moving the wouldOverflow check to guard the sumOfNLegs call itself, not just what happens with its result: the function is now only ever invoked on probes the closed-form oracle has already certified as overflow-free, before any Rational arithmetic runs. The overflow side of the search updates highOverflow directly from the oracle's verdict, with no Rational call at all. Re-verified: same exact boundary (9,223,372,037), ~0.15s runtime, full morph_tests suite still 1077/1077 (20139 assertions). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Fixed a wrong API reference: Rational::operator-() const is a MEMBER unary negation, not the free binary operator-(lhs,rhs) subtraction also declared in rational.hpp -- the plan cited the wrong one. - Resolved a real design gap the plan left as 'reusing that private implementation, not duplicating it' with no concrete mechanism: extracted a new storeJournalImpl private helper (mirroring Task 12's setCategoryImpl precedent exactly) that UndoTransaction calls with negated legs, rather than either duplicating execute(StoreTransaction)'s insert logic or reentrantly calling its public overload (which would double-log and drag in opId/cascade logic meaningless for a reversal). - Resolved a genuinely novel key-resolution question (UndoTransaction only naturally carries journalId, but every keyed action in this file derives its key from a ledgerId field) by adding a redundant ledgerId field to the action itself, keeping ActionKeyTraits::key() a trivial field read instead of introducing an unprecedented DB-lookup-inside- key() pattern. - Fixed the reversal's own date to morph::time::Timestamp::now() (the same client-observable-date convention StoreTransaction.date already uses) -- the plan had cited morph::ladder::now(), the server-audit- stamp convention reserved for LogEntry::timestampMs, not a journal's own date field. - Wrote out the Step 1 test's full body (was a placeholder comment with no code) using the DB-lookup-for-journal-id pattern this file's own DTOs require, since GetLedgerResult/StoreTransaction's return value never exposes a journal id. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds UndoTransaction { ledgerId, journalId } and
LedgerModel::execute(UndoTransaction) -> GetLedgerResult, which inserts a
second, reversing TransactionJournalRecord whose legs are the original
legs negated via Rational::operator-() const (member unary negation),
with causalParentId pointing at the undone journal row. Never uses
morph::journal::undoLast() -- the ledger's own journal is an audit
trail, so undo is a new, visible entry.
ledgerId is redundant with journalId but keeps ActionKeyTraits::key() a
trivial field read like every other keyed action in this file; execute()
independently verifies the looked-up journal's own ledger matches
action.ledgerId.
Extracts LedgerModel::storeJournalImpl (mirroring setCategoryImpl's
role as a single-caller helper) from execute(StoreTransaction)'s
journal-insert + leg-insert + buildLedgerState rebuild, minus the
opId-ledger-write and cascade-evaluation blocks that stay inline in
execute(StoreTransaction) itself. UndoTransaction is the sole caller.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four real gaps found and resolved before dispatch:
1. ImportOpId already exists (Task 11b created it specifically for this
task's reuse) -- the brief said to define a new one.
2. ledger_imported_ops's real unique key is (owner_principal, op_id),
confirmed against the actual entity/migration -- the brief said
(ledgerId, opId), a key the table has no column for.
3. No account information anywhere in the brief's CSV format or
ImportLedgerChunk, despite every transaction needing >=2 legs against
real accounts -- raised to the user, ruled: add a required
counterAccountId field, extend the CSV format with an account_id
column, each row posts a two-leg entry against its own account and
the chunk-wide counter-account.
4. Test snippets hardcoded LedgerId{1} with no backing LedgerRecord ever
created -- every other test in this file creates a real row first;
fixed in the rewritten test code.
Also: reuses Task 14's storeJournalImpl (not a new insert-path
duplicate); specifies exact-arithmetic decimal-string-to-Rational
parsing (never std::stod/atof, which would reintroduce the float
imprecision Rational's entire design avoids); scopes the opId-ledger
table to be populated but not yet read back for an early-return this
task's own test doesn't actually need (recorded as a ruling, not a
TODO) -- content-hash dedup alone already gives both of the task's real
tests their correct behavior.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pulated Adds ImportLedgerChunk/ImportResult (import_dto.hpp) and LedgerModel::execute(ImportLedgerChunk): parses date,description, account_id,amount CSV rows, posts a two-leg entry per row against the row's own account and the chunk's shared counterAccountId, and skips (never throws) any row whose content hash already exists in ledger_imported_txn_hashes for this ledger. Amounts are parsed by hand into morph::math::Rational -- never through std::stod/atof. ledger_imported_ops is populated per chunk (guarded by a lookup so a replayed opId does not violate its (owner_principal, op_id) UNIQUE index) but deliberately not read back for an early-return: it stores no result payload, so an early return could only produce a zeroed ImportResult, under-reporting a genuine replay's real counts. A replay is still a safe no-op -- the content-hash check catches the re-parsed identical rows on its own. This is a deliberate scope narrowing, not an unresolved TODO. Per-row commits (via the existing storeJournalImpl helper) rather than one transaction wrapping the whole chunk: storeJournalImpl opens and commits its own Lightweight::SqlTransaction on the same connection, and nesting a second one around the loop would have the inner Commit() silently end the outer transaction early. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…L snapshot)
Dispatched a dedicated research pass before writing this task's brief,
since the brief's own text flagged a genuine, unresolved uncertainty
("confirm the exact API against whatever rung 2's own README/spec
documents; if unavailable... use ThreadPoolExecutor::post directly").
Findings, all load-bearing:
1. No worker-pool-from-inside-a-model seam exists anywhere in this
codebase -- exhaustively confirmed (every model file in bank/
bookmarks/pastebin/polls). The design spec's own claim that rung 2
"establishes" one for this purpose is not actually true: bookmarks'
real background job lives entirely at the App/Bridge/RemoteServer
layer, re-entering the model as a fresh client dispatch, never
callable from inside a bare model's own execute(). Raised to the
user; ruled: LedgerModel gets its own IExecutor member, a genuinely
new local pattern -- filed as finding 003.
2. Confirmed the real raw-query API for WAL snapshot pinning:
Lightweight::SqlStatement{connection}.ExecuteDirect(rawSql), with a
raw BEGIN DEFERRED needed first (Lightweight::SqlTransaction itself
issues no BEGIN, only toggles ODBC autocommit -- confirmed against
the vendored source and an existing raw-BEGIN precedent in
db_busy_fixture.hpp).
3. ReportJobRecord::jobId (a string column) and ReportJobId (an int64
strong id) are a genuine type mismatch nothing exercised before this
task -- resolved by storing the row's own stringified id.
4. Ledger's own model code hasn't adopted the pooled-DataMapper
convention every later rung uses -- adopted for this task's own new
worker-thread code specifically, not retrofitted onto existing
execute() methods.
5. Confirmed no deferred/deterministic executor test double exists for
testing the worker-pool side of an async job -- every real precedent
genuinely spins a real thread pool with bounded polling, matching
the brief's own already-correct test shape.
Also fixed two smaller issues found while writing out the full
implementation: GetReportStatus's key derivation (repeating Task 14's
already-rejected DB-lookup-inside-key() pattern was considered and
rejected again, in favor of keying directly on jobId), and the nullable
resultJson field's assignment shape (needs an explicit std::optional
wrap, unlike the existing non-nullable AppliedOpRecord::resultJson).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…owned executor Adds SubmitReport/GetReportStatus (design spec §9): SubmitReport creates a Pending ledger_report_jobs row, returns its ReportJobId immediately, and posts the aggregation to a background executor; GetReportStatus polls that row and hands back the serialized body once the job reaches Done. This is the first cross-thread code in LedgerModel, and the first place in this codebase where a model's own execute() posts background work. No framework seam exists for that -- every other "background job" here lives at the App/Bridge/RemoteServer layer and re-enters its model as an ordinary client dispatch, a layer this rung does not have. LedgerModel therefore grows its own shared_ptr<morph::exec::IExecutor> (a single-thread ThreadPoolExecutor by default), filed as docs/findings/003-no-model-level-background-job-seam.md. Thread-boundary discipline: the posted lambda captures only plain values (the job's integer id and the ledger id, both copied). Nothing from execute()'s stack frame crosses over -- not its DataMapper, not the thread-local session Context -- since execute() returns long before the worker runs. The worker acquires its own pooled DataMapper on the worker thread (the later-rung GlobalDataMapperPool convention; existing execute() overloads in this file are deliberately left on their bare DataMapper). The aggregation runs inside a pinned read snapshot: a raw BEGIN DEFERRED issued via SqlStatement::ExecuteDirect as the first statement on the worker's connection, before any Query<T>(), then COMMIT on both the success and the throw path (a read transaction left open holds a SHARED lock that blocks every writer on every other connection). Lightweight::SqlTransaction cannot substitute -- it only toggles SQL_ATTR_AUTOCOMMIT and issues no BEGIN of its own. The job row's own status/result write happens only after that snapshot is released, so the connection never holds a read lock while asking for a write one. A catch-all around the whole worker records Failed so a poller can never spin against Pending forever. ReportJobRecord::job_id (a string column) and ReportJobId (an int64 strong id) are reconciled here for the first time: job_id stores the row's own stringified id, keeping the column consistent with `id` rather than dead schema, with no migration needed. GetReportStatus keys on jobId rather than a ledgerId it does not carry -- resolving one via a DB lookup inside key() is the pattern Task 14 already rejected, and ModelKeyTraits<LedgerModel>::PrimaryKey is std::int64_t either way. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ll a pooled connection for 60s
Task 16's own review found a real, production-shaped bug: the sequential
ExecuteDirect("BEGIN DEFERRED")/ExecuteDirect("COMMIT") pair left two
paths where a connection could be returned to the pool with the read
transaction still open -- BEGIN DEFERRED itself throwing (outside any
try block), or the recovery COMMIT on the exception path itself throwing
(a real SQLITE_BUSY-on-commit possibility, which replaces the in-flight
exception and used to propagate with the transaction still live).
Lightweight::DataMapperPool::Return performs no transaction cleanup on
a returned connection, so either path silently hands the open read lock
to whichever unrelated caller acquires that connection next, which then
blocks for the full 60s busy_timeout on its first write.
Fixed with WalSnapshotGuard, an RAII wrapper whose constructor issues
BEGIN DEFERRED and whose destructor issues COMMIT unconditionally,
swallowing any commit failure (nothing left to report at that point,
and it must never mask whatever exception is already propagating).
Every path out of the pinned scope -- normal return, or any exception
from computeReportJson -- now runs exactly one COMMIT, with no window
where the connection could be returned mid-transaction.
Re-verified: same 120/120 assertions across the full suite, [reports]
subset stable across 3 repeated runs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… not just current behavior CLAUDE.md's own documentation rule: comments state only current behavior + rationale, never 'used to'/'had thrown'/changelog framing. WalSnapshotGuard's doc comment and its call site's comment both violated this (referencing the prior sequential-ExecuteDirect shape and its specific failure history) -- rewritten to state only why the guard's unconditional-COMMIT-on-destruction behavior matters now, not what it replaced. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Filed four issues from this session's discoveries: - morph#129: no framework seam for a model's own execute() to post background work (finding 003) - morph#130: Rational has no checked-arithmetic mode (finding 001) - morph#131: Rational's setWire clamps hostile wire input instead of rejecting (finding 002) - Lightweight#583: DataMapperPool::Return performs no transaction cleanup on a returned connection (new finding 004 -- discovered and fixed at the application layer during Task 16's review, filed against the vendored Lightweight dependency since the gap is in its own pool contract, not morph's) Also recorded fastcached#51 (the stale-cache-entry detour investigated during Task 13) in the SDD progress ledger. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sting precedent examples/ledger/CMakeLists.txt already disables the machine-local fastcache-cc/fastcached compiler-cache launcher for ladder_ledger_lib/ ladder_ledger_tests, having independently discovered the exact same stale-cache-entry bug class this session's Task 13 investigation later rediscovered for tests/test_ledger_rational_fuzz.cpp (which lives under the separate, un-opted-out morph_tests target) -- a debug print added directly to the source never appeared in the executed binary, across repeated rebuilds, surviving a full FastCached service restart. Applies the identical opt-out to morph_tests for the same reason. CI is unaffected: fastcache-cc is only found/enabled when a daemon actually answers on the build machine, never true in CI runners -- this is a local-development-experience fix only. Filed as LASTRADA-Software/fastcached#51. Verified: reconfigured with FASTCACHE_ADDR re-enabled, confirmed morph_tests's real link command no longer references fastcache-cc, full rebuild clean, both morph_tests (1077/1077, 20139 assertions) and ladder_ledger_tests (38/38, 120 assertions) pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The working ledger at .superpowers/sdd/2026-08-19-ledger-rung5/progress.md is git-ignored scratch workspace per the SDD skill's own convention, so it never made it into this branch/PR despite being the full detailed record of every task's outcome, every review finding, and every ruling made across Tasks 1-16. Committing a point-in-time snapshot alongside the plan/spec it belongs with, so the reasoning behind this PR survives the local checkout it was produced in. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test_ledger_model.cpp compared category.Value().value() (an unsigned long -- the raw FK scalar Light::BelongsTo::Value() returns) directly against *categoryA/*categoryB (std::int64_t, from CategoryId's dereference) at three call sites. clang-cl on Windows didn't flag this under -Weverything, but GCC and Linux Clang's -Wsign-compare (both under -Werror) correctly caught it -- confirmed as the sole cause of four failed CI legs (Application ladder, Linux/all-optional-features on both gcc and clang, Linux/clang-coverage), all failing on the identical three lines. Fixed with an explicit static_cast<std::int64_t> on the unsigned side at each comparison, matching this file's own existing idiom elsewhere (static_cast<std::int64_t>(row.id.Value()) at every AccountId/LedgerId/ etc. construction site in this same file). Re-verified locally: ladder_ledger_tests rebuilds clean, 120/120 assertions still pass (unchanged from before this fix -- this was a warning-level compile issue, not a logic change). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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.
Summary
Rung 5 of the application ladder (
ledger— double-entry personal finance, exactRationalarithmetic, multi-currency, budgets, categorization rules, journal-as-audit). This PR covers Tasks 1–16 of a 26-task implementation plan, not the complete rung — the remaining tasks (local-time boundary handling, GUI presenters/bridges, QML views, multi-client stress test, sync-philosophy write-up, coverage gate) are already fully drafted in the plan document and ready to resume once the framework-level findings below are triaged.Implemented so far:
LedgerModel:OpenAccount,GetLedger,StoreTransaction(per-currency zero-sum invariant, foreign-amount pairs, exactly-once via opId),UndoTransaction(compensating action, neverundoLast()), CSV import with two dedup layers (chunk-retry opId ledger + cross-import content-hash),SubmitReport/GetReportStatus(submit→poll job with SQLite read-snapshot pinning)RuleModel+ cascade-journaling (causal parent-id, rule-version pinning so replay reproduces the recorded outcome even if the rule is edited later)BudgetModel: budgets, limits, in-model spent-so-far aggregationRationaloverflow fuzz test (measures the exact real overflow boundary empirically) and a pre-decode validation-gap testEvery task went through independent implementation → controller-verified rebuild+test → dedicated review → fix-loop-on-real-findings, tracked in this session's SDD ledger. Two genuinely new architectural decisions were needed and are called out below since they may warrant framework-level follow-up rather than staying as one-off per-rung workarounds.
Framework-level findings filed as issues (not fixed in this PR — out of scope for an example app to fix the framework it's stress-testing)
execute()to post work to a background executor and later update its own state;SubmitReporthad to grow a local, model-ownedIExecutormember as a workaround.Rationalhas no checked-arithmetic mode; the fuzz test in this PR measures the real overflow boundary (9,223,372,037rows at ledger-realistic magnitudes) empirically.Rational::setWireclamps hostile wire input (e.g.den: 0) instead of rejecting it at decode time.DataMapperPool::Returnperforms no transaction cleanup on a returned connection; a connection returned mid-transaction is silently inherited by the next caller, which can then stall for the fullbusy_timeouton its first write. Fixed at the application layer in this PR (WalSnapshotGuard, an RAII guard around the report job's read-snapshot pinning) but the gap is in the vendored dependency's own pool contract.fastcache-ccis never present on CI runners. TwoCMakeLists.txtchanges in this PR (examples/ledger/,tests/) opt the affected targets out of the launcher as a local-development-experience mitigation.All four are also recorded as
docs/findings/001-004in this PR, per the ladder's own findings-pipeline convention (examples/FINDINGS.md), each cross-linking its issue.Testing
morph_tests: 1077/1077 test cases, 20,139 assertionsladder_ledger_tests: 38/38 test cases, 120 assertionsMORPH_BUILD_DOCUMENTATION=ON): clean, no warnings (matches CI'sWARN_AS_ERROR=FAIL_ON_WARNINGS)scripts/check_spec_citations.sh: cleaninclude/morph/journal/**changes are matched by adocs/spec/journal/journal.mdupdate (satisfies the repo's spec-sync gate)🤖 Generated with Claude Code