Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/findings/r5-001-rational-checked-arithmetic-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ title: Rational has no checked-arithmetic mode; intermediate cross-terms can ove
subsystem: units
severity: minor
source: ledger rung 5, design spec §7
disposition: open
disposition: fix-scheduled
test: tests/test_ledger_rational_fuzz.cpp
issue: https://github.com/LASTRADA-Software/morph/issues/130
---

At ledger-realistic magnitudes (dp=2 currencies, legs up to 10^9 minor units), Rational::operator+ summed over exactly 9,223,372,037 rows (INT64_MAX / 10^9, plus one) crosses int64_t's range, which is undefined behavior today (Rational's arithmetic operators are fixed-width, not saturating, and not exception-throwing by signature -- see include/morph/util/rational.hpp). This exact boundary is empirically confirmed by tests/test_ledger_rational_fuzz.cpp via a binary search that exercises the real Rational::operator+ at each candidate boundary (not a hand-computed estimate) -- see that test for the measurement method. A checked-arithmetic mode (an expected<Rational, Overflow>-returning operator+/- alongside the existing noexcept ones, or a debug-mode overflow assertion) would let a ledger-scale application detect this before committing corrupted state, rather than relying on the app never summing enough rows to hit the boundary in practice.
At ledger-realistic magnitudes (dp=2 currencies, legs up to 10^9 minor units), plain `Rational::operator+`/`operator-` summed over exactly 9,223,372,037 rows (INT64_MAX / 10^9, plus one) crosses int64_t's range, which is undefined behavior on those operators (they are fixed-width, not saturating, and not exception-throwing by signature -- see include/morph/util/rational.hpp). This exact boundary is empirically confirmed by tests/test_ledger_rational_fuzz.cpp via a binary search that exercises the real Rational::operator+ at each candidate boundary (not a hand-computed estimate) -- see that test for the measurement method.

**Update (morph#130).** A checked-arithmetic mode now exists: `morph::math::checkedAdd`/`checkedSub`/`checkedMul`/`checkedDiv` (include/morph/util/rational.hpp) return `std::expected<Rational, RationalError>` and detect overflow rather than invoking it, so a ledger-scale application can adopt these free functions on the summation path to catch the boundary above before committing corrupted state. Disposition is `fix-scheduled` rather than closed because the primitive existing is not the same as ledger's own summation loop having adopted it -- that adoption is separate application-layer work.
6 changes: 4 additions & 2 deletions docs/findings/r5-002-rational-no-predecode-validation-seam.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ title: No pre-decode validation seam for Rational -- setWire clamps hostile wire
subsystem: wire
severity: minor
source: ledger rung 5, design spec §7
disposition: open
disposition: fix-scheduled
test: examples/ledger/tests/test_ledger_model.cpp (clamped Rational leg test)
issue: https://github.com/LASTRADA-Software/morph/issues/131
---

A wire payload like {"num":5,"den":0,"dp":2} decodes via Rational::setWire into a plausible 5/1 rather than being rejected at decode time (see include/morph/util/rational.hpp's codec). Every dispatch path decodes before any model-level validate() runs, so an app has no seam to catch a clamped value as clamped -- it only ever sees an already-plausible Rational. Ledger's own zero-sum invariant happens to catch most clamped legs incidentally (a clamped value is unlikely to still sum to zero), but this is coincidental protection from a business rule, not a validation guarantee the framework provides. A pre-decode validation hook (reject rather than clamp, or a decode-time flag surfacing "this value was clamped") would close the gap for any app whose own invariants don't happen to catch it.
A wire payload like {"num":5,"den":0,"dp":2} decodes via Rational::setWire into a plausible 5/1 rather than being rejected at decode time (see include/morph/util/rational.hpp's codec). Ledger's own zero-sum invariant happens to catch most clamped legs incidentally (a clamped value is unlikely to still sum to zero), but this is coincidental protection from a business rule, not a validation guarantee the framework provides.

**Update (morph#131).** The framework now rejects a clamped Rational on the path this finding is actually about -- an action arriving over the wire. `BRIDGE_REGISTER_ACTION`'s generated `ActionTraits<A>::fromJson` (include/morph/core/registry.hpp) wraps every action-body decode in `morph::math::WireClampScope`, a per-thread RAII scope that counts every clamp `Rational::setWire` performs while it is live, and throws `ParseError` if the count is nonzero -- so `StoreTransaction`'s own `fromJson` (ledger uses plain `BRIDGE_REGISTER_ACTION`, which expands to this) now refuses a clamped leg before the model ever sees it, closing the gap this finding raised for the dispatch path. What is not closed: a `Rational` decoded directly (`glz::read_json` on a bare `Rational`, outside an action envelope's `fromJson`) is not wrapped in a `WireClampScope` by anything -- the finding's own regression test exercises exactly that direct-decode shape, so it still demonstrates the residual gap and still passes today only because ledger's zero-sum check happens to catch it, same as before. Disposition stays `fix-scheduled` rather than `documented-limitation` because the *dispatch-path* half of this finding is closed but the *direct-decode* half is not, and nothing in `docs/spec/` yet documents direct `Rational` decode as an accepted gap.
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ This finding is in the vendored `Lightweight` dependency, not morph itself -- fi

`Lightweight::DataMapperPool::Return` (and `~PooledDataMapper`) performs no transaction cleanup on a connection being returned to the pool: no `SQLEndTran`, no autocommit reset, no cursor close. A connection returned while a SQL transaction is still open (e.g. a raw `BEGIN DEFERRED`/`COMMIT` pair around a WAL-style read snapshot, per `IMPLEMENTATION.md` rule 4's escape tier) is silently handed to whichever unrelated caller acquires that connection next, which then blocks on its first write for the driver's own `busy_timeout` (60000ms in this codebase) before surfacing `SQLITE_BUSY`.

Discovered and fixed at the application layer in `ledger::LedgerModel::execute(SubmitReport)`'s background report-job worker: two real paths could leave a connection returned mid-transaction (the raw `BEGIN DEFERRED` itself throwing before any cleanup ran, or a recovery `COMMIT` on an exception-unwind path itself throwing and replacing the in-flight exception). Fixed with an RAII guard (`WalSnapshotGuard`) whose destructor always issues exactly one `COMMIT`, swallowing any failure, on every exit path including exception unwinding -- but this is a per-caller workaround for a gap in the pool's own connection-lifecycle contract, not a framework-level fix.
Discovered and fixed at the application layer in `ledger::LedgerModel::execute(RunReportJob)`, the background report-job worker that actually aggregates the report (`SubmitReport` only enqueues a `Pending` row; the worker that later runs the job is `RunReportJob`): two real paths could leave a connection returned mid-transaction (the raw `BEGIN DEFERRED` itself throwing before any cleanup ran, or a recovery `COMMIT` on an exception-unwind path itself throwing and replacing the in-flight exception). Fixed with an RAII guard (`WalSnapshotGuard`) whose destructor always issues exactly one `COMMIT`, swallowing any failure, on every exit path including exception unwinding -- but this is a per-caller workaround for a gap in the pool's own connection-lifecycle contract, not a framework-level fix.
5 changes: 5 additions & 0 deletions docs/superpowers/specs/2026-08-19-ledger-rung5-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ spec (`docs/superpowers/specs/2026-08-16-kanban-rung4-design.md`, unmerged),
quoted or restated rather than assumed, since that file does not exist on
`master` yet.

**Update: PR #121 has since merged.** Rung 4 (kanban) and its design spec
are both on `master` now; the parallel-construction rationale above is
historical context for why this branch was cut before that merge, not a
description of the tree's current state.

**Scope**: steps 1–7 of the README's build order (accounts + transactions
with the per-currency zero-sum invariant, multi-currency, budgets, rules +
cascade-journaling, undo-as-compensation, CSV/OFX import with dedup,
Expand Down
5 changes: 3 additions & 2 deletions examples/LADDER.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,13 @@ later rungs consume earlier answers (5 reuses 4's cascade-journaling answer,
| 3 | [`polls`](polls) | [Rallly](https://github.com/lukevella/rallly) | Shared instances, anonymous principals, undo, event polling |
| 4 | [`kanban`](kanban) | [Kanboard](https://github.com/kanboard/kanboard) | Strand ordering under concurrency, RBAC, offline queue + replay, action cascades |
| 5* | [`ledger`](ledger) | [Firefly III](https://github.com/firefly-iii/firefly-iii), [Actual Budget](https://github.com/actualbudget/actual) | Exact `Rational` arithmetic under invariants, multi-currency, sync-philosophy benchmark |
| 6* | [`lims`](lims) | [SENAITE](https://github.com/senaite/senaite.core), [InvenTree](https://github.com/inventree/InvenTree), [ODK Central](https://github.com/getodk/central) | Unit algebra, versioned schema-driven forms, offline entities with conflict detection |
| 6 | [`lims`](lims) | [SENAITE](https://github.com/senaite/senaite.core), [InvenTree](https://github.com/inventree/InvenTree), [ODK Central](https://github.com/getodk/central) | Unit algebra, versioned schema-driven forms, offline entities with conflict detection |
| 7* | [`crm`](crm) | [EspoCRM](https://github.com/espocrm/espocrm), [Tryton](https://github.com/tryton/tryton), [Frappe](https://github.com/frappe/frappe) | Metadata-driven forms, dynamic logic, per-field authz; **7b** (gated): runtime custom fields |
| 8* | [`forge`](forge) | [Gogs](https://github.com/gogs/gogs), [Gitea/Forgejo](https://github.com/go-gitea/gitea), GitLab architecture | Everything at once: orgs/permissions, notifications at scale, webhooks, out-of-protocol sidecars |

\* = design annex: README is the deliverable; construction is a post-rung-4
decision (ledger first in line; forge → load script; crm → 7b spike).
decision (ledger under construction; lims built; forge → load script; crm →
7b spike).

## Cross-cutting stress map

Expand Down
20 changes: 13 additions & 7 deletions examples/ledger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,20 @@ Build order (status as of rung 5's implementation, see
compensating action, CSV import with dedup, and the submit->poll report
pair -- each with model, presenter, QML bridge and tests. The desktop
client wiring all four bridges is in `gui/`.
- **Step 8: prose delivered, one scenario's test pending.**
- **Step 8: prose and both scenarios delivered.**
`SYNC-BENCHMARK.md` states the philosophy and both scenarios in full.
Scenario B is reproducible against `UpdateRule`'s real version conflict.
Scenario A -- two offline clients editing the same *transaction* -- has no
surface to exercise yet: this rung ships no transaction-edit action, no
base version on `transaction_journals`, and no offline-queue wiring. That
gap is tracked in morph#144 rather than papered over with a test that
would pass under the scenario's name without testing it.
Scenario B is reproducible against `UpdateRule`'s real version conflict
(`expectedVersion`, `VersionConflict`). Scenario A -- two offline clients
editing the same *transaction* -- is recorded as **inapplicable**, not
implemented: this rung ships no transaction-edit action by design (a posted
journal entry is an audit record, corrected by a new compensating entry per
design spec §6, never edited in place), so §10's scenario presumes a
capability §6 rules out. Running the collision this rung *can* express
instead -- two clients both reversing the same transaction offline -- found
a real bug (both `UndoTransaction`s applied, doubling the reversal), fixed
by `causal_parent_id` naming what a compensating entry reverses and a second
reversal being rejected with `AlreadyReversed`. morph#144 tracked both
halves and is closed.


1. Accounts + `StoreTransaction { description, date, legs[] }` — one
Expand Down
Loading
Loading