diff --git a/docs/spec/offline/offline.md b/docs/spec/offline/offline.md index c5007106..7341aff7 100644 --- a/docs/spec/offline/offline.md +++ b/docs/spec/offline/offline.md @@ -375,6 +375,12 @@ void submit(const MyAction& action) { } ``` +That free function is app-layer by design, not by omission: +`examples/IMPLEMENTATION.md` rule 1 would otherwise keep this code inside a +model, and +[Disposition: app-layer by design](#disposition-app-layer-by-design-the-rule-1-carve-out) +below is the recorded carve-out that puts it here. + `SyncWorker` closes the loop on the *read path*: on reconnect it `drain()`s the same queue and replays each payload. The two halves share one `IOfflineQueue` instance (see [End-to-end integration](#end-to-end-integration)) — the @@ -387,6 +393,73 @@ entirely the caller's (`QueueItem::payload` is an opaque `std::string`), and it is the caller's responsibility that the same format round-trips through the `SyncWorker::ReplayFunction`. +### Disposition: app-layer by design (the rule-1 carve-out) + +The example above puts domain-adjacent code in a free function at the dispatch +site. `examples/IMPLEMENTATION.md` rule 1 would otherwise forbid exactly that +placement — "nothing domain-shaped may live in presenters, QML, `main()`, or +free functions." The placement is deliberate, and this section is its recorded +disposition (morph#197), so a reader who finds +`if (!monitor.isOnline()) queue.enqueue(...)` outside a model knows it is a +sanctioned exception rather than an oversight. + +**Rule 1 is not being overridden here; it fired.** Its final clause is "If +logic can't be expressed in a model, that is a finding," and that document's +prime directive says the same of the framework itself. This carve-out *is* +that finding's outcome, not an argument that the rule is wrong. + +**Why a model cannot host it.** `enqueue()` is the write path's last act before +the wire, taken precisely when the wire is unavailable. In the canonical wiring +this file and `ARCHITECTURE.md` both show, the client keeps an in-process +`LocalBackend`, so a client-side model does exist and could in principle own +the decision. On a **remote deployment** — models behind a server, reached over +a `Bridge` — it does not: the one machine that must decide "queue this instead +of sending it" is the one machine with no model on it. morph offers no seam +there, and none of the framework's own offline types fills the gap: neither +`NetworkMonitor` nor `ReconnectCoordinator` enqueues, and the queue stays +passive by design (above). + +**What the carve-out covers, and what it does not.** + +| Belongs in the write-path seam | Stays in the model | +|---|---| +| Probing `NetworkMonitor::isOnline()`, or catching a failed `execute()` | Validating and authorizing the action (`Context::principal`) | +| Minting the idempotency key, serialising the payload, calling `enqueue()` | Deduping the replayed op against the journal | +| Surfacing queue depth to the UI | Classifying a stale base version as a conflict | +| Client-local bookkeeping that keeps *this client's own* queued items consistent — e.g. a per-entity version ledger so a second offline edit chains onto the first rather than colliding with it | Everything the payload *means* once it lands: replay re-dispatches it as an ordinary typed action | + +The last row of the left column is the sharp edge: it is genuinely +domain-shaped, and the carve-out sanctions it only in a **dedicated app-layer +write-path class** — never in a presenter, a QML bridge, or `main()`. The +domain semantics of the queued action never move out of the model; only the +decision to queue it, and the client-local state that decision needs, live in +the seam. + +**Reference shapes in the ladder.** + +- `examples/lims/include/lims/offline/field_outbox.hpp` — the reference for the + domain-shaped half. A plain, non-Qt, app-layer class that stamps each queued + capture with a base version from its own local ledger and advances that + ledger on enqueue, so a client's second offline edit chains onto its own + pending first edit. Replay still goes through the model + (`SampleModel::execute(QueuedCapture)`), which owns validation, authorization + and conflict classification. +- `examples/kanban/gui_lib/board_qml_bridge.cpp` — the transport-shaped half + only: probe, mint an op id, serialise, enqueue, update queue depth. It lives + in a presenter, which rule 1 names as forbidden for *domain-shaped* code; + nothing there is domain-shaped, so it stands as glue under rule 2's + "pure glue with no domain logic" justification. Anything with a domain + invariant in it belongs in a `FieldOutbox`-shaped class instead. + +**Scope, and when to revisit.** This is the "explicitly dispositioned in the +spec as app-layer by design" branch of `examples/IMPLEMENTATION.md`'s promotion +rule, taken at **two** occurrences of the transport-shaped seam (kanban's +bridge, lims' outbox) and **one** of the domain-shaped version chaining (lims' +outbox). No framework primitive is owed yet. A third rung independently growing +its own enqueue-on-offline path is the trigger to reopen the question of a +framework-owned outbox dispatcher — a standing disposition must not become the +reason a third reinvention goes unexamined. + ## SyncWorker Replays queued actions from an `IOfflineQueue` on reconnect. Drains the queue diff --git a/examples/IMPLEMENTATION.md b/examples/IMPLEMENTATION.md index 09ee1695..183e540e 100644 --- a/examples/IMPLEMENTATION.md +++ b/examples/IMPLEMENTATION.md @@ -28,6 +28,27 @@ The user-code contract is: **you implement Models; morph exposes them.** plain, single-threaded model classes with typed actions — nothing domain-shaped may live in presenters, QML, `main()`, or free functions. If logic can't be expressed in a model, that is a finding. + - **Named carve-out: the offline write-path enqueue seam.** Deciding + *"the backend is unreachable — queue this action instead of sending it"* + is app-layer by design and sits at the dispatch site, outside any model. + This is not an exemption from the rule; it is the rule's last clause + having fired. The finding was raised + ([morph#197](https://github.com/LASTRADA-Software/morph/issues/197)) and + dispositioned as app-layer under the promotion rule above; the reasoning, + the boundary, and the reference shapes are in + [`docs/spec/offline/offline.md`](../docs/spec/offline/offline.md) + ("Ownership: who enqueues" → "Disposition: app-layer by design"). The + carve-out covers the decision to queue and the client-local bookkeeping + that decision needs — probe/mint-key/serialise/enqueue/queue-depth, plus + a per-entity local version ledger so a client's own successive offline + edits chain instead of colliding. Everything the queued action *means* + on replay — validation, authorization, dedup, conflict classification — + stays in the model, which replay re-dispatches through as usual. The + sanctioned home is a dedicated app-layer write-path class + (`examples/lims/include/lims/offline/field_outbox.hpp` is the reference), + **not** a presenter: kanban's copy in a QML bridge + (`examples/kanban/gui_lib/board_qml_bridge.cpp`) stands only because it + is transport-shaped glue under rule 2's glue justification. - Follow [`bank`](bank/README.md)'s established shape: `BRIDGE_REGISTER_*` macros in the model header so every call site sees the `ActionTraits` specialisation; stateful models keyed with `BRIDGE_KEY_FROM`/ diff --git a/examples/lims/README.md b/examples/lims/README.md index 61ab1027..5da4ac7c 100644 --- a/examples/lims/README.md +++ b/examples/lims/README.md @@ -361,7 +361,9 @@ The write half could not use a framework seam either: there is no enqueue-on-failure hook, and the machine that must make the decision (a disconnected field client) has no model on it at all. `include/lims/offline/field_outbox.hpp` is the app-layer answer, and -morph#197 is the finding. +morph#197 is the finding — now dispositioned: `IMPLEMENTATION.md` rule 1 +carries a named carve-out for this seam, and `FieldOutbox` is the shape it +points at (`docs/spec/offline/offline.md`, "Disposition: app-layer by design"). So neither end of the offline round trip goes through a framework seam. That is the honest summary of §7's framework story. @@ -716,7 +718,10 @@ files no uploaded report contained. Fixed here. application's job at the dispatch site; and a disconnected field client has no model to put it in anyway. `FieldOutbox` is this rung's app-layer answer, and it carries a real invariant (a client's own successive offline edits must - chain), not glue. + chain), not glue. **Dispositioned as app-layer by design:** rule 1 names the + carve-out and `docs/spec/offline/offline.md` records the reasoning and the + boundary; a framework primitive is reconsidered when a third rung grows its + own enqueue path. - **[morph#172](https://github.com/LASTRADA-Software/morph/issues/172) — `MORPH_BUILD_OFFLINE_SQLITE=ON` breaks the build on macOS with a non-Apple clang.** `FindSQLite3` resolves the SDK's whole `/usr/include`, which is then diff --git a/examples/lims/include/lims/offline/field_outbox.hpp b/examples/lims/include/lims/offline/field_outbox.hpp index 91d6647a..849340f2 100644 --- a/examples/lims/include/lims/offline/field_outbox.hpp +++ b/examples/lims/include/lims/offline/field_outbox.hpp @@ -16,16 +16,21 @@ /// @file /// The field client's **write path** for offline capture (README §7). /// -/// @par Why this is not a model, and why that is a finding +/// @par Why this is not a model, and why that is sanctioned /// `examples/IMPLEMENTATION.md` rule 1 says all domain logic lives in models. /// It cannot here. `docs/spec/offline/offline.md` ("Ownership: who enqueues") /// is explicit that the framework supplies no seam for it — *"detecting an /// offline/failed `execute()` and calling `enqueue()` is the application's -/// job"*, and its own worked example puts that code at the dispatch site. A -/// disconnected field client has no model to put it in either: a rung's models -/// live server-side behind Lightweight/ODBC (rule 4's WASM clause), so the one -/// machine that must decide "queue this instead of sending it" is the one -/// machine with no model on it. See morph#197. +/// job"*, and its own worked example puts that code at the dispatch site. This +/// rung's field client is deployed remotely: its models live server-side +/// behind Lightweight/ODBC and are reached over a `Bridge`, so the one machine +/// that must decide "queue this instead of sending it" is the one machine with +/// no model on it. Filed as morph#197 and dispositioned there: rule 1 now +/// carries a named carve-out for the offline write-path enqueue seam, and this +/// class is the carve-out's reference shape for the domain-shaped half (see +/// `docs/spec/offline/offline.md`, "Disposition: app-layer by design"). The +/// domain semantics stay in the model — replay re-dispatches each queued item +/// through `SampleModel::execute(QueuedCapture)`. /// /// @par What it actually does: chain a client's own edits /// The trap the README names, and the one ODK Central hit: a client that edits