Skip to content

ledger: give rung 5 an App layer and move the report job's scheduling out of the model - #243

Open
Yaraslaut wants to merge 2 commits into
masterfrom
fix/160-ledger-app-layer
Open

ledger: give rung 5 an App layer and move the report job's scheduling out of the model#243
Yaraslaut wants to merge 2 commits into
masterfrom
fix/160-ledger-app-layer

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Closes #160.

Which of the two options, and why

Option 1: give ledger an App layer — with one correction to the issue's own suggested fix.

#160's step 2 says "the App layer picks up pending jobs, computes, and dispatches an action carrying the result". Taken literally that would put a monthly-statement aggregation in the App, which examples/IMPLEMENTATION.md rule 1 forbids: all business logic lives in models. The precedent the issue cites doesn't do that either — bookmarks::app::App does only the non-domain half (fetch a URL) and dispatches RecordMetadata for the domain half.

So the split here is: the App decides when, the model computes what.

  • execute(SubmitReport) writes a Pending row carrying kind and params, and returns. No executor, no thread. ledger_model.hpp no longer includes <morph/core/executor.hpp> — it was the only ladder model that did.
  • New execute(RunReportJob) holds the aggregation the posted lambda used to, unchanged apart from where it gets its params.
  • New ledger::app::App owns the worker pool and a QTimer that sweeps ledger_report_jobs for Pending rows and fire-and-forget dispatches RunReportJob through an internal-client Bridge carrying kReportRunnerPrincipal, with the stopBackgroundJobs() / reportsInFlight() settle seam bookmarks and pastebin both have.

Option 2 ("something smaller") isn't actually available: the only smaller move is making the model's executor injectable, which is #222 and leaves the model owning it.

One thing that contradicts #160

The issue says "bookmarks, polls and kanban each have an Appbookmarks::App::fetchMetadataOnce() is the working precedent, including its service-principal token minting for the re-entering dispatch." That precedent does not transfer, and the reason is worth reading before reviewing app.hpp:

RemoteServer::dispatchExecute clears the session principal whenever the authorizer's authenticate() returns nullopt — correct per docs/spec/security.md, and the default for AllowAllAuthorizer. Rung 5 ships no src/auth/, no AuthModel, no token secret. So a RemoteServer-based App is not merely unauthenticated here; it is one on which no action can carry a principal, and every mutating LedgerModel action refuses an empty one.

I hit this as a real failure, not by reading:

[ERROR] [ledger::App] RunReportJob dispatch failed for job 1: RunReportJob: only the report runner may run a report job

The runner therefore dispatches over a LocalBackend — which is also exactly the backend this rung's shipped client uses (AppContext's Local mode) — and App owns no RemoteServer and no server() accessor. That is documented at length in app.hpp rather than left to be rediscovered. For the same reason this PR adds no src/server/main.cpp: an unauthenticated financial server is not a thing to ship. Rung 5's missing login/authorizer/server story is filed as #242, not folded in here.

In-flight jobs at teardown

The most interesting consequence, and it is an improvement rather than a regression.

Before: a job was a lambda in a ThreadPoolExecutor the model owned. ~ThreadPoolExecutor drains its queue before joining, so a job in flight when the model died did finish — but only inside that exact process. A process that was killed lost the job entirely, with nothing anywhere that could tell it had ever been submitted, and the row stayed Pending forever.

After: the job is the row. A pass is "find the Pending rows, dispatch each". So:

  • A job accepted by a process that then dies is still Pending, and the first pass of the next runner picks it up. test_app.cpp's "A job submitted before the App existed is picked up by its first pass" is exactly that scenario, with two App scopes standing in for two processes.
  • This is why SubmitReport::params had to become a persisted column (params_json, new nullable ALTER TABLE migration 20260819000014): the params used to be decoded on the submitting thread and captured into the lambda, so they never needed to outlive it. A later runner has nothing but the row.
  • A teardown that does not drain is still safe for the data — the aggregation either committed or it did not, and an uncommitted job is simply still Pending. What it is not safe for is App's own completion callbacks, hence reportsInFlight() and the pastebin-style _reportExecutor-declared-first member ordering.
  • The cost, stated plainly in app.hpp: a job whose aggregation crashes the process is retried on restart rather than abandoned. A job whose aggregation merely throws is recorded Failed and is not retried.

There is no src/server/main.cpp in this rung to add a drain to, so the stopBackgroundJobs()-then-drain-then-destroy sequence is exercised by test_app.cpp and by test_report_presenter.cpp instead. When rung 5 gets a server (#242), that main copies bookmarks' drainMetadataFetches against reportsInFlight().

Interaction with #222 — direct collision

#222 and this PR cannot both land as they stand. #222 adds explicit LedgerModel(std::shared_ptr<IExecutor>) so a test can inject a deterministic executor for _reportExecutor. This PR deletes _reportExecutor, so that constructor has nothing left to inject and the <morph/core/executor.hpp> include it depends on is gone.

Branched from master as instructed, not from #222. Concretely:

Suggested resolution, if the maintainer agrees: land step_executor.hpp + its own test_step_executor.cpp + the TESTING.md entry from #222, and drop #222's LedgerModel constructor and its test_ledger_reports.cpp conversions. I have not touched #222's branch.

Verification

Measured locally, on this branch, with -DCMAKE_CXX_COMPILER=/opt/homebrew/opt/llvm/bin/clang++ and QT_QPA_PLATFORM=offscreen:

  • ladder_ledger_tests builds warning-free and passes: 392 assertions in 79 test cases.
  • bash scripts/check_spec_citations.shProse lint OK.

Mutation check (AGENTS.md, "ask whether the check would still pass if the feature did nothing"), two mutations, each built and run, each reverted:

  1. App::runPendingReportsOnce() returns immediately after its query, dispatching nothing → 7 failed (72 passed | 7 failed): all five test_app.cpp cases, plus ReportQmlBridge drives submit->poll to done and The App's report timer settles a job with no pass driven by hand.
  2. execute(RunReportJob) skips its finishReportJob(..., Done, ...)12 failed (67 passed | 12 failed), spanning both test_app.cpp and test_ledger_reports.cpp (SubmitReport returns immediately…, Re-polling the same completed job…, Running an already-settled job recomputes nothing, SubmitReport stores its params on the job row…).

Left to CI, not measured here: the Application ladder job's full-ladder build and every other rung's suite; the Docs (Doxygen FAIL_ON_WARNINGS) job over the new public symbols; sanitizer and coverage presets; every non-macOS platform. Only ledger's own targets were configured locally (-DMORPH_LADDER_RUNGS=ledger), deliberately — another agent was compiling on the same machine.

Filed in passing, not folded in

… out of the model

`ledger::LedgerModel` owned a one-thread `ThreadPoolExecutor` and
`execute(SubmitReport)` posted the report aggregation to it, making it the
only ladder model that included `<morph/core/executor.hpp>` -- a model that
had become framework-aware in order to schedule work. The cause was a
missing layer, not a bad call: rung 5 had no `src/app/`, so the job had
nowhere else to go (closes #160).

Rung 5 now has one. `ledger::app::App` owns the worker pool and a periodic
report runner that sweeps `ledger_report_jobs` for `Pending` rows and
dispatches `RunReportJob` back at the model through an internal client
bridge carrying a service principal -- the same shape
`bookmarks::app::App`'s metadata worker already had.

The aggregation itself did NOT move to the App. A monthly statement is
business logic, and `examples/IMPLEMENTATION.md` rule 1 puts business logic
in models; only the decision of when it runs is orchestration. So
`execute(SubmitReport)` writes a `Pending` row and returns, and the new
`execute(RunReportJob)` holds the computation the posted lambda used to.

Consequences worth naming:

* The run happens on the strand for its own ledger, so a report and a
  concurrent `StoreTransaction` against the same book serialise instead of
  racing. The WAL read snapshot stays: `BudgetModel` writes from a strand of
  its own.
* A job is now a row, not a queued lambda, so it survives the process that
  accepted it -- a runner starting later picks it up. `SubmitReport::params`
  is persisted (new nullable `params_json` column) because the row is the
  only record of the request a later runner has.
* `RunReportJob` is idempotent: an already-terminal job is left untouched
  and its status returned, which is what makes the runner's re-dispatch of a
  still-outstanding job harmless.
* The App owns no `RemoteServer`. `RemoteServer` clears the session
  principal for any authorizer that does not authenticate, and this rung
  installs no authorizer, so every mutating action would arrive with an
  empty principal and be refused. The runner dispatches over a
  `LocalBackend` -- the backend this rung's shipped client already uses.

Tests: `test_ledger_reports.cpp` loses its poll-and-sleep helper entirely --
the run is a synchronous action now, so there is nothing to wait for -- and
gains cases for the Pending-until-run property, params round-tripping
through the database, the principal gate, and re-run idempotency. New
`test_app.cpp` covers the runner, including a job outliving the App that
accepted it. `test_report_presenter.cpp` now runs a client and an App
together, which is what the deployment actually is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.66667% with 34 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
examples/ledger/src/app/app.cpp 62.50% 20 Missing and 1 partial ⚠️
examples/ledger/src/models/ledger_model.cpp 72.50% 10 Missing and 1 partial ⚠️
...ples/ledger/include/ledger/models/ledger_model.hpp 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

Added while raising this PR's patch coverage (65.69%, the lowest of the
eleven open PRs). The uncovered lines in src/app/app.cpp are its error arms
-- the `.then()` branch that logs a Failed result and the `.onError()`
branch with its `describe()` helper -- so the obvious test was a job whose
aggregation fails.

**It does not fail.** A job pointing at a ledger that no longer exists finds
no accounts, produces `[]`, and settles *Done*: measured `status=1 body=[]`.
That is indistinguishable from a successful report over a real ledger with no
activity, which for an auditable-records rung is the wrong default. Filed as
morph#250; this test asserts today's behaviour so it cannot change silently
while that is open, and deliberately pins the property that matters either
way -- the row reaches a *terminal* state, since a row left Pending is what a
poller spins on forever.

No test is added for the two error arms, because they are not reachable
through any existing seam and a test that executes a line without asserting
anything is worth less than the honest gap:

  * a missing ledger yields an empty report, as above;
  * malformed params_json does not throw -- decodeMonthlyParams returns
    nullopt and the report falls back to all-time;
  * DbFaultFixture contends on a *named* advisory lock and LedgerModel takes
    none, so it cannot fault the aggregation.

Covering them needs a fault seam that does not exist, which is a design
decision rather than a missing test. Recorded in morph#250 alongside the
behaviour that led there.

Suite: 397 assertions in 80 cases, ladder_ledger_tests, clang -Weverything
-Werror.
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::LedgerModel owns a ThreadPoolExecutor: rung 5 has no App layer, so SubmitReport's job runs inside the model

1 participant