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
44 changes: 22 additions & 22 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -96,28 +96,28 @@ target_sources(morph
FILE_SET HEADERS
BASE_DIRS include
FILES
include/morph/logger.hpp
include/morph/executor.hpp
include/morph/strand.hpp
include/morph/completion.hpp
include/morph/model.hpp
include/morph/action_log.hpp
include/morph/journal.hpp
include/morph/file_action_log.hpp
include/morph/rational.hpp
include/morph/quantity.hpp
include/morph/datetime.hpp
include/morph/choice.hpp
include/morph/forms.hpp
include/morph/registry.hpp
include/morph/backend.hpp
include/morph/remote.hpp
include/morph/bridge.hpp
include/morph/network_monitor.hpp
include/morph/offline_queue.hpp
include/morph/sync_worker.hpp
include/morph/session.hpp
include/morph/wire.hpp
include/morph/core/logger.hpp
include/morph/core/executor.hpp
include/morph/core/strand.hpp
include/morph/core/completion.hpp
include/morph/core/model.hpp
include/morph/core/registry.hpp
include/morph/core/backend.hpp
include/morph/core/remote.hpp
include/morph/core/bridge.hpp
include/morph/core/wire.hpp
include/morph/journal/action_log.hpp
include/morph/journal/journal.hpp
include/morph/journal/file_action_log.hpp
include/morph/offline/network_monitor.hpp
include/morph/offline/offline_queue.hpp
include/morph/offline/sync_worker.hpp
include/morph/session/session.hpp
include/morph/forms/forms.hpp
include/morph/forms/choice.hpp
include/morph/util/rational.hpp
include/morph/util/quantity.hpp
include/morph/util/datetime.hpp
)

# ── Demo executable ──────────────────────────────────────────────────────────
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ with `checkpoint()` coalescing and `undoLast()`, which reconstructs state by
*replaying* the remaining actions. This model is exact for pure, deterministic,
in-memory models; a model that owns an external store (SQL, network) needs care,
because replay re-executes actions rather than re-applying stored results — see
the design notes in [`docs/spec/journal.md`](docs/spec/journal.md).
the design notes in [`docs/spec/journal/journal.md`](docs/spec/journal/journal.md).

## Offline support

Expand Down Expand Up @@ -344,7 +344,7 @@ the `Context` is client-supplied data with no built-in integrity, the default
authorizer allows everything, and the local backend does not authorize at all.
Treat authentication as the transport's job (e.g. TLS + a token your authorizer
validates) and enforce security-critical checks inside the model. See
[`docs/spec/session.md`](docs/spec/session.md).
[`docs/spec/session/session.md`](docs/spec/session/session.md).

## Building & dependencies

Expand Down
159 changes: 105 additions & 54 deletions docs/ARCHITECTURE.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions docs/planned/backend_changed_dispatch.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Compile-time `onBackendChanged` dispatch (planned)

> **Status: planned — not yet implemented.** This spec extends
> [backend.md](../spec/backend.md) and [bridge.md](../spec/bridge.md). It removes the RTTI
> [backend.md](../spec/core/backend.md) and [bridge.md](../spec/core/bridge.md). It removes the RTTI
> `dynamic_cast` sweep in `LocalBackend::notifyBackendChanged` documented as a
> limitation in `backend.md`. See [todo.md](../todo.md).

Expand Down Expand Up @@ -140,14 +140,14 @@ interaction is identical.

## Cross-references

- [backend.md](../spec/backend.md) — `LocalBackend::notifyBackendChanged`, the
- [backend.md](../spec/core/backend.md) — `LocalBackend::notifyBackendChanged`, the
`IBackendChangedSink` limitation ("uses RTTI over every model under the
registry lock") this removes, and register/deregister where `_changeAware` is
maintained.
- [bridge.md](../spec/bridge.md) — `switchBackend` → `notifyBackendChanged`, and the
- [bridge.md](../spec/core/bridge.md) — `switchBackend` → `notifyBackendChanged`, and the
posted-to-strand `onBackendChanged` contract this preserves.
- [concurrency_and_lifetimes.md](../spec/concurrency_and_lifetimes.md) — the
strand-serialised, fires-once, on-the-new-instance guarantees that are
unchanged by this refactor.
- [registry.md](../spec/registry.md) — `ModelHolder<M>`, `BackendChangedNotifiable`, the
- [registry.md](../spec/core/registry.md) — `ModelHolder<M>`, `BackendChangedNotifiable`, the
`BackendChangedMixin` compile-time detection reused here.
16 changes: 8 additions & 8 deletions docs/planned/connection_scoped_cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

> **Status: planned — not yet implemented.** This spec closes the "no
> connection-scoped cleanup — orphaned models leak" limitation documented in
> [backend.md](../spec/backend.md): models registered over a connection that
> [backend.md](../spec/core/backend.md): models registered over a connection that
> dies stay registered on the server forever. It extends `RemoteServer` and
> `QtWebSocketServer` ([backend.md](../spec/backend.md)) with an **opt-in
> `QtWebSocketServer` ([backend.md](../spec/core/backend.md)) with an **opt-in
> connection scope**, and complements
> [transport_limits.md](transport_limits.md) and
> [instance_authorization.md](instance_authorization.md). See
Expand All @@ -13,7 +13,7 @@
## The gap

`RemoteServer` is connection-blind by design: `handle()` takes a message and a
reply callback, nothing more ([backend.md](../spec/backend.md),
reply callback, nothing more ([backend.md](../spec/core/backend.md),
[transport_limits.md](transport_limits.md)). Models it creates on `register`
live in `_models` until an explicit `deregister` arrives. Nothing ever arrives
for a connection that dies:
Expand All @@ -24,7 +24,7 @@ for a connection that dies:
stays live until process exit.
- **The client cannot reliably clean up either.** `QtWebSocketBackend`'s
`deregisterModel` is deliberately fire-and-forget (no nested event loop in a
destructor — [backend.md](../spec/backend.md) design decisions), and a
destructor — [backend.md](../spec/core/backend.md) design decisions), and a
crashed or power-cut client never sends anything at all. The header itself
warns: "an undelivered or lost `deregister` leaves the model registered on
the server indefinitely."
Expand All @@ -39,7 +39,7 @@ for a connection that dies:
The client side already treats model ids as connection-scoped: on disconnect
`QtWebSocketBackend` cancels its pending calls, and after a reconnect the
`Bridge` re-registers every live handler through the reconnect handler
([backend.md](../spec/backend.md)) — fresh registrations, fresh ids. Old ids
([backend.md](../spec/core/backend.md)) — fresh registrations, fresh ids. Old ids
are never reused by a correct client. Only the server side pretends they are
still meaningful.

Expand Down Expand Up @@ -130,7 +130,7 @@ it merely prevents *new* lookups, exactly like an explicit `deregister`.
reclaims the connection's instances.
- **[instance_authorization.md](instance_authorization.md):** opaque id
generation is orthogonal; the recorded owner is erased with the instance.
- **`RemoteServer::LogProvider` logs ([journal.md](../spec/journal.md)):**
- **`RemoteServer::LogProvider` logs ([journal.md](../spec/journal/journal.md)):**
erasing the holder releases its reference to the attached `IActionLog`; the
log object itself is shared and host-owned, so recorded history survives —
consistent with "entries are never removed by the framework".
Expand All @@ -142,7 +142,7 @@ it merely prevents *new* lookups, exactly like an explicit `deregister`.

- **No session resumption.** Model ids stay connection-scoped; a reconnecting
client re-registers and gets fresh ids — that is the *existing* contract
(`Bridge` re-registration on reconnect, [backend.md](../spec/backend.md)),
(`Bridge` re-registration on reconnect, [backend.md](../spec/core/backend.md)),
which this spec makes the server honour rather than changes. A host that
wants ids to survive reconnects must keep using the unscoped path.
- **No lease/TTL expiry.** Unscoped registrations (embedded hosts,
Expand Down Expand Up @@ -177,7 +177,7 @@ it merely prevents *new* lookups, exactly like an explicit `deregister`.

## Cross-references

- [backend.md](../spec/backend.md) — the documented limitation this closes;
- [backend.md](../spec/core/backend.md) — the documented limitation this closes;
`RemoteServer` register/deregister/execute paths; the fire-and-forget
`deregisterModel` design decision; the reconnect-handler re-registration
contract the cleanup relies on.
Expand Down
14 changes: 7 additions & 7 deletions docs/planned/drift_guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ code**. [todo.md](../todo.md) enumerates the actual drift that shipped:

- the `authenticate` "principal-clearing" behavior a doc described wrongly,
- the false "unknown keys ignored" claim that predated the real
`error_on_unknown_keys = false` behavior ([wire.md](../spec/wire.md)),
`error_on_unknown_keys = false` behavior ([wire.md](../spec/core/wire.md)),
- a stale `runFor` comment,
- a stale `AuthError` cardinality (the enum grew, the doc did not).

Expand Down Expand Up @@ -44,9 +44,9 @@ The facts, drawn from the audit's failure classes:
| Fact class | Examples (verified real symbols) | Source of truth in code |
|---|---|---|
| **Enum cardinalities** | `AuthError` (`Malformed`/`BadSignature`/`Expired`/`NotYetValid`), `LogLevel` (5: `debug`/`info`/`warn`/`error`/`off`), `ReconnectOutcome` (3), `Metric` (once [observability.md](observability.md) lands) | the `enum class` declaration in the header |
| **Key constants** | `kMaxEnvelopeBytes` (`8 * 1024 * 1024`, [wire.md](../spec/wire.md)), `kMaxDecimalPlaces` ([ARCHITECTURE.md](../ARCHITECTURE.md), `morph::math`), `kClockSkewMs` (60s, [security.md](../spec/security.md)) | the `constexpr` definition |
| **Canonical error strings** | `BackendChangedError` = `"backend changed before completion resolved"`, `BridgeDestroyedError`, `DisconnectedError` ([backend.md](../spec/backend.md)); `err "unauthorized"`, `"model not found"`, `"register requires a typeId"` | the string literal in the throw/reply site |
| **Glaze behavior flags** | `error_on_unknown_keys = false` on `wire::decode` ([wire.md](../spec/wire.md)); duplicate-key last-wins (behavioral, asserted by a pinned test) | the `glz::read<{...}>` options at the call site |
| **Key constants** | `kMaxEnvelopeBytes` (`8 * 1024 * 1024`, [wire.md](../spec/core/wire.md)), `kMaxDecimalPlaces` ([ARCHITECTURE.md](../ARCHITECTURE.md), `morph::math`), `kClockSkewMs` (60s, [security.md](../spec/security.md)) | the `constexpr` definition |
| **Canonical error strings** | `BackendChangedError` = `"backend changed before completion resolved"`, `BridgeDestroyedError`, `DisconnectedError` ([backend.md](../spec/core/backend.md)); `err "unauthorized"`, `"model not found"`, `"register requires a typeId"` | the string literal in the throw/reply site |
| **Glaze behavior flags** | `error_on_unknown_keys = false` on `wire::decode` ([wire.md](../spec/core/wire.md)); duplicate-key last-wins (behavioral, asserted by a pinned test) | the `glz::read<{...}>` options at the call site |

The manifest is the one place a human states "the spec claims X"; the code is
scanned for the actual value; CI fails if they diverge. When a value legitimately
Expand Down Expand Up @@ -134,14 +134,14 @@ as the Doxygen `FAIL_ON_WARNINGS` gate `CLAUDE.md` already documents.

## Cross-references

- [wire.md](../spec/wire.md) — `kMaxEnvelopeBytes`, the
- [wire.md](../spec/core/wire.md) — `kMaxEnvelopeBytes`, the
`error_on_unknown_keys = false` flag, and the duplicate-key behavior — three of
the exact facts the audit found drifted.
- [security.md](../spec/security.md) — `AuthError` cardinality and `kClockSkewMs`,
audit-class facts this pins.
- [backend.md](../spec/backend.md) — the canonical error-type `what()` strings and
- [backend.md](../spec/core/backend.md) — the canonical error-type `what()` strings and
reply strings (`"unauthorized"`, `"model not found"`) pinned as literals.
- [logger.md](../spec/logger.md) — `LogLevel`'s 5-member cardinality.
- [logger.md](../spec/core/logger.md) — `LogLevel`'s 5-member cardinality.
- [api_stability.md](api_stability.md) — the complementary compatibility guard;
drift-guard checks *doc accuracy*, api-stability checks *API compatibility*.
- `CLAUDE.md` — the "specs are authoritative; update the spec on any change" rule
Expand Down
24 changes: 12 additions & 12 deletions docs/planned/durable_offline_queue_impl.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
> store that implements the `QueueItem::attempts` / `setAttempts` persistence
> contract that spec defines (and thereby makes its `SyncWorker`-side
> `DeadLetterSink` fire across restarts) — and extends
> [offline.md](../spec/offline.md). See [todo.md](../todo.md).
> [offline.md](../spec/offline/offline.md). See [todo.md](../todo.md).

## The gap

Only `InMemoryOfflineQueue` ships. [offline.md](../spec/offline.md) is blunt about
Only `InMemoryOfflineQueue` ships. [offline.md](../spec/offline/offline.md) is blunt about
it: "`InMemoryOfflineQueue` loses everything on exit. A durable/SQL-backed
`IOfflineQueue` is the caller's to write (the interface is designed for it, but no
implementation is provided here)."
Expand All @@ -32,7 +32,7 @@ not from an `IOfflineQueue`.)

Ship one reference durable `IOfflineQueue` that persists `payload`,
`idempotencyKey`, and `attempts` across process restarts, correctly implementing
the [offline.md](../spec/offline.md) queue contract and the
the [offline.md](../spec/offline/offline.md) queue contract and the
[durable_queue.md](durable_queue.md) `setAttempts` write-back — so B1's
cross-restart dead-lettering actually works without every host re-implementing
the store. It is an **optional** component: `InMemoryOfflineQueue` stays the
Expand Down Expand Up @@ -77,7 +77,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS ix_queue_idem
- `id` is `AUTOINCREMENT`, so ids are never reused and a re-opened queue
re-presents each row under its **stored** `id` (stable across restarts in this
implementation). It remains **queue-local** — the
[offline.md](../spec/offline.md) contract ("Queue-local — not a
[offline.md](../spec/offline/offline.md) contract ("Queue-local — not a
cross-subsystem key") allows but never promises stability, so callers must not
rely on it. Cross-restart identity is carried by `idempotency_key`, not `id`.
- The partial unique index on a non-empty `idempotency_key` gives the insert-time
Expand All @@ -87,7 +87,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS ix_queue_idem
re-enqueue of the same key is a no-op (`INSERT ... ON CONFLICT DO NOTHING`)
that returns the existing row's id. This is a deliberate, implementation-
specific strengthening: the base `IOfflineQueue` contract only *stores* the
key and never enforces uniqueness ([offline.md](../spec/offline.md)). Empty
key and never enforces uniqueness ([offline.md](../spec/offline/offline.md)). Empty
keys (the default) are exempt, so keyless items behave exactly as the
in-memory queue does.

Expand Down Expand Up @@ -115,14 +115,14 @@ protected:

- **Crash-safety matches the interface contract.** `drain()` never deletes, so a
crash between `drain()` and `markDone()` loses nothing — items reappear on the
next `drain()` ([offline.md](../spec/offline.md)'s "`drain` is non-destructive"
next `drain()` ([offline.md](../spec/offline/offline.md)'s "`drain` is non-destructive"
decision). Each write (`enqueue`, `markDone`, `setAttempts`,
`setIdempotencyKey`) is a committed transaction; SQLite's WAL provides the
durability.
- **Thread-safety matches `InMemoryOfflineQueue`.** All operations serialise on an
internal mutex around the connection, so it is safe to share between the
application's enqueue-on-failure write path and `SyncWorker`'s drain/replay read
path ([offline.md](../spec/offline.md)'s "Ownership: who enqueues").
path ([offline.md](../spec/offline/offline.md)'s "Ownership: who enqueues").

### A plain-file variant

Expand All @@ -136,7 +136,7 @@ over an append-only NDJSON file (mirroring `FileActionLog`'s shape,
- `markDone` and `setAttempts` are recorded as tombstone/update lines and
compacted on open (last-write-wins per id), so the file is self-healing across
restarts and tolerates a torn trailing line the same way `FileActionLog` does
([journal.md](../spec/journal.md), "Torn-write tolerance").
([journal.md](../spec/journal/journal.md), "Torn-write tolerance").
- Same `IOfflineQueue` surface; the choice between SQLite and file is a host
decision, not a contract difference.

Expand All @@ -152,7 +152,7 @@ With this queue installed as the `IOfflineQueue` behind `SyncWorker`
3. `SyncWorker` resumes from the stored count, so the 5-attempt budget is
cumulative across restarts and the item genuinely dead-letters (invoking the
`DeadLetterSink`) instead of retrying forever — closing the exact failure mode
[offline.md](../spec/offline.md) documents.
[offline.md](../spec/offline/offline.md) documents.

## What this does not do

Expand All @@ -165,12 +165,12 @@ With this queue installed as the `IOfflineQueue` behind `SyncWorker`
`InMemoryOfflineQueue` are untouched and dependency-free.
- **No dedup enforcement beyond insert.** The unique index deduplicates a
re-enqueue of the same key at write time, but replay-time at-most-once is still
the consumer's job via `idempotencyKey` ([offline.md](../spec/offline.md) — "the
the consumer's job via `idempotencyKey` ([offline.md](../spec/offline/offline.md) — "the
queue only *stores* it"). The queue does not become an exactly-once engine.
- **Not the transactional outbox.** A model with its own store that needs the log
and its state to commit atomically uses [outbox.md](outbox.md); this queue is
the *offline write buffer*, a distinct concern.
- **No conflict resolution.** As [offline.md](../spec/offline.md) states, that
- **No conflict resolution.** As [offline.md](../spec/offline/offline.md) states, that
lives in the model's `onBackendChanged()`, not the queue.

## Testing (planned)
Expand All @@ -196,7 +196,7 @@ With this queue installed as the `IOfflineQueue` behind `SyncWorker`
`setAttempts` write-back hook this queue concretely implements, and the
`SyncWorker`-side `DeadLetterSink` whose cross-restart dead-lettering it
makes real.
- [offline.md](../spec/offline.md) — `IOfflineQueue`, `QueueItem`,
- [offline.md](../spec/offline/offline.md) — `IOfflineQueue`, `QueueItem`,
`InMemoryOfflineQueue`, `enqueue`/`drain`/`markDone`/`setIdempotencyKey`, the
non-destructive-`drain` crash-safety contract, `idempotencyKey` dedup, and the
"only an in-memory queue ships" limitation this closes.
Expand Down
6 changes: 3 additions & 3 deletions docs/planned/durable_queue.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Durable offline queue & cross-restart dead-lettering (planned)

> **Status: planned — not yet implemented.** This spec extends
> [offline.md](../spec/offline.md). It closes the "retry counter is in-memory and resets
> [offline.md](../spec/offline/offline.md). It closes the "retry counter is in-memory and resets
> on restart" and "dead-lettering is log-only" limitations documented there. See
> [todo.md](../todo.md).

Expand Down Expand Up @@ -159,11 +159,11 @@ them.

## Cross-references

- [offline.md](../spec/offline.md) — `IOfflineQueue`, `QueueItem`, `InMemoryOfflineQueue`,
- [offline.md](../spec/offline/offline.md) — `IOfflineQueue`, `QueueItem`, `InMemoryOfflineQueue`,
`SyncWorker`, the `setIdempotencyKey` hook this mirrors, and the two Failure
modes ("retry counter resets on restart", "dead-lettering is log-only") this
closes.
- [journal.md](../spec/journal.md) — the permanent audit trail, distinct from the
- [journal.md](../spec/journal/journal.md) — the permanent audit trail, distinct from the
transient queue; `idempotencyKey` is the shared token that lets a replay
consumer dedup across both.
- [error_handling.md](../spec/error_handling.md) — the swallow-and-treat-as-failure
Expand Down
Loading
Loading