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
73 changes: 73 additions & 0 deletions docs/spec/offline/offline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
21 changes: 21 additions & 0 deletions examples/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`/
Expand Down
9 changes: 7 additions & 2 deletions examples/lims/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
17 changes: 11 additions & 6 deletions examples/lims/include/lims/offline/field_outbox.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading