Summary
No framework-level seam exists for a model's own execute() to post work to a background executor and later update its own persisted state. Every existing "background job" pattern in the ladder examples (bookmarks' metadata-fetch worker) lives at the App/Bridge/RemoteServer layer and re-enters the model as a fresh, ordinary, fully-authorized client dispatch (through a minted service-principal token) — never something a plain model instance with no App/Bridge/RemoteServer around it can do.
Discovered while implementing rung 5 (ledger)'s SubmitReport/GetReportStatus submit→poll job (design spec called for "the job runs off the model's own strand ... following the ladder-wide 'background jobs' pattern from LADDER.md's ... internal-client-with-service-principal seam that rung 2 establishes" — this claim turned out not to be accurate; see below).
Evidence
morph::exec::IExecutor/ThreadPoolExecutor (include/morph/core/executor.hpp) has zero usages anywhere inside a model's own execute() across examples/bank, examples/bookmarks, examples/pastebin, examples/polls. Every real usage sits at the App/server layer (examples/bank/include/bank/app/app.hpp, examples/bookmarks/include/bookmarks/app/app.hpp, examples/bank/src/cli/main.cpp).
- There is no process-wide singleton executor analogous to
morph::journal::setActionLog's own install pattern — ThreadPoolExecutor has no set/get global accessor.
- Bookmarks' real background job (
examples/bookmarks/src/app/app.cpp, App::fetchMetadataOnce()) reads rows needing metadata via a raw SqlStatement, then for each dispatches a new client call through morph::bridge::BridgeHandler<BookmarkModel> — which itself posts through the worker pool and re-enters BookmarkModel::execute(const RecordMetadata&) as an ordinary, fully-authorized dispatch with a minted auth::kMetadataFetcherPrincipal service-principal token. BookmarkModel::execute(const RecordMetadata&) itself is synchronous, ordinary model code — it never touches an executor.
App::fetchMetadataOnce() is driven by a QTimer, i.e. triggered from the Qt event loop outside the model entirely — not something a headless/non-Qt model consumer (or a model with no App wrapper at all, like ledger::LedgerModel) could reach.
Impact
Any future rung/model that needs a genuine "submit now, compute later, poll for status" job (report generation, batch export, long-running aggregation) has no sanctioned framework mechanism to reach for, and must invent its own per-model workaround — exactly what ledger::LedgerModel did (a private std::shared_ptr<morph::exec::IExecutor> member, posting directly from execute(SubmitReport)). This works, but:
- it duplicates a pattern every future model would reinvent slightly differently,
- it has no defined service-principal/session-propagation story for the worker thread (the ledger implementation avoided this by never touching the calling thread's session — the worker re-derives everything it needs from plain copied values — but a job that genuinely needs to act as a principal on the worker thread has no guidance),
- it cannot be tested with a deferred/deterministic executor double the way client-callback executors can (
morph::ladder::testkit::DeterministicExecutor/StepExecutor exist for controlling delivery order of continuations, but no test anywhere substitutes a double for the worker side of an async job — every real async-job test genuinely spins a real thread pool and polls with a bounded, wall-clock-deadline retry loop).
Suggested direction
A first-class "background task from inside a model" primitive, with:
- a defined way for a model to reach a shared (or model-owned) executor without inventing its own member each time,
- a documented service-principal/session-propagation convention for code that runs on the posted task and needs to act with authority (mint a token, or explicitly document "the worker re-derives its own state and never inherits session context"),
- a deferred/synchronous test double for the worker side specifically, so async-job unit tests don't need to spin real threads with real sleeps to get coverage.
Reference
Filed from LASTRADA-Software/morph, branch ladder-ledger-rung5 (not yet merged), as docs/findings/003-no-model-level-background-job-seam.md. See that file for the same summary in the ladder's own findings-pipeline format (examples/FINDINGS.md).
Summary
No framework-level seam exists for a model's own
execute()to post work to a background executor and later update its own persisted state. Every existing "background job" pattern in the ladder examples (bookmarks' metadata-fetch worker) lives at the App/Bridge/RemoteServer layer and re-enters the model as a fresh, ordinary, fully-authorized client dispatch (through a minted service-principal token) — never something a plain model instance with no App/Bridge/RemoteServer around it can do.Discovered while implementing rung 5 (
ledger)'sSubmitReport/GetReportStatussubmit→poll job (design spec called for "the job runs off the model's own strand ... following the ladder-wide 'background jobs' pattern from LADDER.md's ... internal-client-with-service-principal seam that rung 2 establishes" — this claim turned out not to be accurate; see below).Evidence
morph::exec::IExecutor/ThreadPoolExecutor(include/morph/core/executor.hpp) has zero usages anywhere inside a model's ownexecute()acrossexamples/bank,examples/bookmarks,examples/pastebin,examples/polls. Every real usage sits at the App/server layer (examples/bank/include/bank/app/app.hpp,examples/bookmarks/include/bookmarks/app/app.hpp,examples/bank/src/cli/main.cpp).morph::journal::setActionLog's own install pattern —ThreadPoolExecutorhas noset/getglobal accessor.examples/bookmarks/src/app/app.cpp,App::fetchMetadataOnce()) reads rows needing metadata via a rawSqlStatement, then for each dispatches a new client call throughmorph::bridge::BridgeHandler<BookmarkModel>— which itself posts through the worker pool and re-entersBookmarkModel::execute(const RecordMetadata&)as an ordinary, fully-authorized dispatch with a mintedauth::kMetadataFetcherPrincipalservice-principal token.BookmarkModel::execute(const RecordMetadata&)itself is synchronous, ordinary model code — it never touches an executor.App::fetchMetadataOnce()is driven by aQTimer, i.e. triggered from the Qt event loop outside the model entirely — not something a headless/non-Qt model consumer (or a model with no App wrapper at all, likeledger::LedgerModel) could reach.Impact
Any future rung/model that needs a genuine "submit now, compute later, poll for status" job (report generation, batch export, long-running aggregation) has no sanctioned framework mechanism to reach for, and must invent its own per-model workaround — exactly what
ledger::LedgerModeldid (a privatestd::shared_ptr<morph::exec::IExecutor>member, posting directly fromexecute(SubmitReport)). This works, but:morph::ladder::testkit::DeterministicExecutor/StepExecutorexist for controlling delivery order of continuations, but no test anywhere substitutes a double for the worker side of an async job — every real async-job test genuinely spins a real thread pool and polls with a bounded, wall-clock-deadline retry loop).Suggested direction
A first-class "background task from inside a model" primitive, with:
Reference
Filed from
LASTRADA-Software/morph, branchladder-ledger-rung5(not yet merged), asdocs/findings/003-no-model-level-background-job-seam.md. See that file for the same summary in the ladder's own findings-pipeline format (examples/FINDINGS.md).