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
51 changes: 50 additions & 1 deletion docs/spec/core/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ holds a `unique_ptr<IBackend>` and delegates all model operations to it.
| `setReconnectHandler(handler)` | Installs a callback invoked when the backend reconnects to its peer. Fires only on the *second and later* connects, never the first — used by `Bridge` to re-register handlers after a drop. Used by backends with transport (e.g. `QtWebSocketBackend`). Default implementation is a no-op. |
| `setConnectHandler(handler)` | Installs a callback invoked on every successful connect, including the first — the complementary hook `setReconnectHandler` deliberately skips (see [Connect/disconnect notifications](#connectdisconnect-notifications)). Default implementation is a no-op. |
| `setDisconnectHandler(handler)` | Installs a callback invoked whenever the transport drops, before any reconnect is scheduled. Default implementation is a no-op. |
| `setSession(session)` | Installs the `session::Context` stamped onto every control envelope (`register`, `registerShared`, `attach`, `assign`, `deregister`) this backend subsequently builds. Pushed by `Bridge::setDefaultSession()` and `Bridge::switchBackend()`. Default implementation is a no-op. See [Session propagation to control envelopes](#session-propagation-to-control-envelopes). |

## Connect/disconnect notifications

Expand Down Expand Up @@ -116,6 +117,35 @@ its `connected`/`disconnected` `QWebSocket` signal slots invoke
they already invoke `_reconnectHandler`/schedule a reconnect — see that
section below.

## Session propagation to control envelopes

`Bridge::executeVia` stamps `Bridge::defaultSession()` onto the `ActionCall`
passed to `execute()` (see [bridge.md](bridge.md)), so `execute` envelopes
always carry the current session. Control messages — `register`,
`registerShared` (`registerModelShared`), `attach` (`attachModel`), `assign`
(`assignPrimary`), and `deregister` (`deregisterModel`) — are different: each
is built directly inside the concrete backend, which has no other route to
the `Bridge`'s session except `IBackend::setSession`. Every wire-backed
implementation stamps the session `setSession` last installed onto these
envelopes too, so `RemoteServer::authorizeRegister` sees the caller's
identity and the owner principal it records at `register` time reflects the
registering session — which is what `IAuthorizer::authorizeInstance`'s
ownership check relies on for every instance a `Bridge` registers (see
[session.md](../session/session.md)).

`Bridge` calls `IBackend::setSession` in two places: once from its
constructor (with the just-constructed, typically empty, default session) and
again every time `setDefaultSession()` installs a new one; `switchBackend()`
also calls it on the incoming backend, **before** phase 1's
per-binding re-registration loop runs, so every `register`/`registerShared`
envelope built while re-registering handlers on the new backend already
carries the current session. A wire-backed backend that overrides
`setSession` — `SimulatedRemoteBackend`, `SocketBackend`, `QtWebSocketBackend`
— stores the session and reads it back into every control envelope's
`session` field it subsequently builds. `LocalBackend` does not override
`setSession`: the local path never serialises a `Context` onto a wire
envelope, so there is nothing to stamp.

## Asynchronous registration — `registerModelAsync`

`registerModel`/`registerModelWithContext` are synchronous: a backend whose
Expand Down Expand Up @@ -224,6 +254,7 @@ Four exception types are thrown into in-flight `Completion`s:
it never runs under `_regMtx` or `Bridge::_mtx`, so a sink that re-enters the
bridge cannot deadlock.
- `setReconnectHandler`/`setConnectHandler`/`setDisconnectHandler` — no-op (no transport to (dis)connect).
- `setSession` — not overridden (the default no-op stands): the local path never serialises a `Context` onto a wire envelope, so there is nothing to stamp.

Each model instance gets its own strand so actions are serialised per-model
without a global lock on the pool.
Expand Down Expand Up @@ -568,6 +599,10 @@ in-process simulation of remote execution.
locally.
- `cancelPending` snapshots and resolves pending completions, same pattern as
`LocalBackend`.
- `setSession` stores the session (guarded by its own mutex); every
subsequently built `register`/`registerShared`/`attach`/`assign`/`deregister`
envelope's `session` field is set from it before the `handleInline` call —
see [Session propagation to control envelopes](#session-propagation-to-control-envelopes).

**Connection scope.** The default constructor, `SimulatedRemoteBackend(RemoteServer&)`,
carries `ConnectionId{0}` — the server's "unscoped" sentinel — on every call it
Expand Down Expand Up @@ -715,6 +750,12 @@ server assigns fresh ones on the new connection (cross-ref bridge.md).
application installs them directly on the backend (not through `Bridge`) to
drive its own connection-state UI.

**`setSession(session)`** stores the session in `_session` (this backend is
single-threaded — Qt event loop thread only — so no lock is needed); every
subsequently built `register`/`registerShared`/`attach`/`assign`/`deregister`
envelope's `session` field is set from it before sending. See
[Session propagation to control envelopes](#session-propagation-to-control-envelopes).

**`waitForConnected(timeoutMs = 5000)`** pumps the Qt event loop until the socket
connects or the timeout elapses; returns the current `_connected` flag. Intended
to be called once after construction on the Qt thread.
Expand Down Expand Up @@ -939,7 +980,14 @@ peer no longer leaks the model, because `SocketServer` now participates in
`RemoteServer`'s connection-scope contract exactly as `QtWebSocketServer`
does — see below); `execute` assigns a monotonic `callId`, is
fully asynchronous, and supports concurrent in-flight calls matched by
`callId` exactly like the Qt transport. Reconnect is configured by
`callId` exactly like the Qt transport. `setSession` stores the session
under its own mutex (`_sessionMtx`, guarding the one field it protects — this
backend is driven from multiple threads, unlike the single-threaded Qt
transport) and every subsequently built `register`/`registerShared`/
`attach`/`assign`/`deregister` envelope's `session` field is set from it
before sending — see
[Session propagation to control envelopes](#session-propagation-to-control-envelopes).
Reconnect is configured by
`SocketBackendConfig` (aliased `SocketBackend::Config`), with the same four
fields and defaults as `QtWebSocketBackendConfig` (`reconnectEnabled`,
`initialReconnectDelay`, `maxReconnectDelay`, `backoffMultiplier`) plus one new
Expand Down Expand Up @@ -1144,6 +1192,7 @@ thread to marshal onto.
| `setReconnectHandler` | `virtual void setReconnectHandler(const function<void()>&)` | Default: no-op. Fires only on the second and later connects. |
| `setConnectHandler` | `virtual void setConnectHandler(const function<void()>&)` | Default: no-op. Fires on every successful connect, first included. |
| `setDisconnectHandler` | `virtual void setDisconnectHandler(const function<void()>&)` | Default: no-op. Fires whenever the transport drops, before any reconnect is scheduled. |
| `setSession` | `virtual void setSession(session::Context)` | Default: no-op. Stamped onto every control envelope (`register`/`registerShared`/`attach`/`assign`/`deregister`) subsequently built. See [Session propagation to control envelopes](#session-propagation-to-control-envelopes). |

### Error types

Expand Down
24 changes: 19 additions & 5 deletions docs/spec/core/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,14 @@ two overloads:
every existing call site ambiguous. It converts to a `shared_ptr` and
delegates to the overload above.

Both run the same two phases under `_mtx`:
Before either phase, the current default session is pushed onto the new
backend via `newBackend->setSession(...)` (read under `_sessionMtx` alone,
never held while calling into the backend) — so every control envelope phase
1 builds while re-registering handlers on the new backend already carries the
session, exactly as it would on the backend that is being replaced. See
`IBackend::setSession` and [backend.md](backend.md#session-propagation-to-control-envelopes).

Both phases below run under `_mtx`:

- **Phase 1 — stage, do not mutate.** Every live binding is registered on the
new backend and the resulting `(binding, newId)` pairs are collected into a
Expand Down Expand Up @@ -242,7 +249,14 @@ sending a now-destroyed `ModelId` to the backend.

**`setDefaultSession(session)`** / **`defaultSession()`** installs a default
`morph::session::Context` that is attached to every `executeVia()` call.
Thread-safe, separate mutex from `_mtx`.
Thread-safe, separate mutex from `_mtx`. `setDefaultSession` also pushes the
new session to the active backend via `IBackend::setSession` (copied out from
under `_sessionMtx` before the call, never while holding it), so every
control envelope (`register`/`registerShared`/`attach`/`assign`/`deregister`)
the backend subsequently builds carries the session too — not only `execute`
envelopes. The constructor does the same with the (typically empty) initial
session. See [session.md](../session/session.md#how-a-context-originates-and-flows)
and [backend.md](backend.md#session-propagation-to-control-envelopes).

**`setPrincipal(principal)`** / **`currentPrincipal()`** installs and reads
back a `morph::session::Principal` — the verified identity + roles, readable
Expand Down Expand Up @@ -547,14 +561,14 @@ make teardown order-independent.)

| Member | Signature | Notes |
|---|---|---|
| ctor | `explicit Bridge(unique_ptr<IBackend>)` | Installs reconnect handler on the backend. |
| ctor | `explicit Bridge(unique_ptr<IBackend>)` | Installs reconnect handler on the backend, then pushes the (initially empty) default session via `setSession`. |
| dtor | `~Bridge()` | Clears the active backend's reconnect handler, then cancels all pending completions with `BridgeDestroyedError`. |
| `registerHandler<Model>` | `shared_ptr<HandlerBinding> registerHandler()` | Default factory. Prefers `IBackend::registerModelAsync`; see `backend.md`. |
| `registerHandler(binding)` | `void registerHandler(const shared_ptr<HandlerBinding>&)` | Pre-built binding. Same async-preferring behavior. |
| `switchBackend` | `void switchBackend(unique_ptr<IBackend>)` / `void switchBackend(shared_ptr<IBackend>)` | Atomic: stages all re-registrations on the new backend, commits (publishes new ids + swaps) only if all succeed, else rolls back and rethrows leaving old backend + `currentId`s intact. Cancels old backend's pending ops with `BackendChangedError`. Holds both `_mtx` and `_attachMtx` for its duration. The `unique_ptr` overload is a template on the concrete backend type and delegates to the `shared_ptr` one — see below. |
| `switchBackend` | `void switchBackend(unique_ptr<IBackend>)` / `void switchBackend(shared_ptr<IBackend>)` | Pushes the current default session onto the new backend via `setSession` before staging. Atomic: stages all re-registrations on the new backend, commits (publishes new ids + swaps) only if all succeed, else rolls back and rethrows leaving old backend + `currentId`s intact. Cancels old backend's pending ops with `BackendChangedError`. Holds both `_mtx` and `_attachMtx` for its duration. The `unique_ptr` overload is a template on the concrete backend type and delegates to the `shared_ptr` one — see below. |
| `deregisterHandler` | `void deregisterHandler(const shared_ptr<HandlerBinding>&)` | Deregisters from active backend (if bound), resets `currentId` to 0, removes from tracking. |
| `executeVia<Model, Action>` | `Completion<R> executeVia(const shared_ptr<HandlerBinding>&, Action, IExecutor*)` | Lock-free dispatch. Attaches default session. On `LocalBackend`, rejects an action whose `ActionValidator::ready` returns `false` with `morph::model::ValidationError` via `onError`, before `Model::execute` runs. Records a journal `LogEntry` for loggable actions on both success (`Outcome::Succeeded`) and a throwing `Model::execute` (`Outcome::Failed`, rethrown unchanged). Value-forwarding into the typed `Completion` is `try`/`catch`-guarded — a throwing result move/copy resolves the completion via `onError` instead of hanging or terminating. The bridge-touching side effects (`onResult`, `hasSubscribers()`/`publishResult`, and the `pendingCalls()` decrement) are gated on the `_liveness` token, checked before any runs, so a completion resolving after `~Bridge()` skips them instead of touching the dangling `Bridge`. Increments `pendingCalls()` once per call before dispatch (never for the synchronous "handler not bound" early return); decrements it exactly once, from whichever of the two mutually-exclusive resolution continuations actually fires. |
| `setDefaultSession` | `void setDefaultSession(session::Context)` | Installs default session context. |
| `setDefaultSession` | `void setDefaultSession(session::Context)` | Installs default session context; also pushes it to the active backend via `IBackend::setSession` so control envelopes (register/attach/assign/deregister) carry it too, not only `execute`. |
| `defaultSession` | `session::Context defaultSession() const` | Returns snapshot of default session. |
| `setPrincipal` | `void setPrincipal(session::Principal)` | Installs the verified `Principal`, readable outside a dispatch. Pass `Principal{}` to clear (sign-out). |
| `currentPrincipal` | `session::Principal currentPrincipal() const` | Returns a snapshot of the installed `Principal`; default-constructed if none was ever set. |
Expand Down
13 changes: 10 additions & 3 deletions docs/spec/journal/journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,15 @@ the journal gains an entry it previously lacked.

### `toJson(LogEntry const&) -> std::string`

Encodes a `LogEntry` as JSON via Glaze. Throws `SerializationError` on failure
(not realistically reachable for a flat struct of strings/integers — see
Encodes a `LogEntry` as JSON via Glaze, writing with `detail::EscapingWriteOpts`
so a raw ASCII control byte (0x00-0x1F) in `entityKey`/`payload`/`error`/
`principal`/`idempotencyKey` round-trips through `fromJson` instead of
producing invalid JSON — or, when the same string also holds an escaped `\`/`"`,
silently corrupted JSON (glaze's chunked writer path rewrites the control byte
as two `0x00` bytes in that case). Mirrors `morph::wire::detail::EscapingWriteOpts`
(`core/wire.hpp`) exactly; duplicated locally rather than shared so this header
stays free of a `core/` dependency. Throws `SerializationError` on failure (not
realistically reachable for a flat struct of strings/integers — see
`detail::throwOnGlazeError`).

### `fromJson(std::string_view) -> LogEntry`
Expand Down Expand Up @@ -684,7 +691,7 @@ All symbols live in `namespace morph::journal`.
| `LogEntry` | struct | Flat aggregate: `seq`, `modelType`, `entityKey`, `actionType`, `payload`, `result`, `outcome`, `error`, `principal`, `timestampMs`, `idempotencyKey`, `v` (line-format version, default `kLogFormatVersion`). Glaze-reflected (no `glz::meta` of its own; `outcome`'s type `Outcome` has one). |
| `Outcome` | `enum class : std::uint8_t` | `Succeeded` (default) or `Failed`. Has a `glz::meta` specialisation so it (de)serialises as the string, not the underlying int. |
| `kLogFormatVersion` | `inline constexpr std::uint32_t` | Current line-format version (`1`). Bumped only on a breaking change to `LogEntry`'s shape. See [Line-format version (`v`)](#line-format-version-v). |
| `toJson` | free function | `std::string toJson(const LogEntry&)` — encodes as JSON. Throws `SerializationError`. |
| `toJson` | free function | `std::string toJson(const LogEntry&)` — encodes as JSON with `detail::EscapingWriteOpts` (control-byte escaping). Throws `SerializationError`. |
| `fromJson` | free function | `LogEntry fromJson(std::string_view)` — decodes from JSON leniently (`error_on_unknown_keys = false`). Throws `SerializationError` on malformed JSON or if the decoded `v` exceeds `kLogFormatVersion`. |
| `SerializationError` | struct | `: std::runtime_error`. Thrown by `toJson`/`fromJson`. |
| `detail::throwOnGlazeError` | inline function | `void throwOnGlazeError(const glz::error_ctx&, std::string_view)` — shared error path for `toJson`/`fromJson`. |
Expand Down
7 changes: 6 additions & 1 deletion docs/spec/offline/offline.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,12 @@ restarts with **no extra dependency** — it ships in the default `morph` target
alongside `InMemoryOfflineQueue`. Each mutation (`enqueue`, `markDone`,
`setAttempts`, `setIdempotencyKey`) appends one JSON line
(`{"op": "put"|"done", "id", "payload", "idempotencyKey", "attempts"}`) and
immediately `fflush`+`fsync`s it. On open, the file is replayed
immediately `fflush`+`fsync`s it. The line is written with
`detail::EscapingWriteOpts` (mirroring `morph::wire::detail::EscapingWriteOpts`,
`core/wire.hpp`) so a raw ASCII control byte in `payload`/`idempotencyKey`
round-trips instead of producing invalid JSON that breaks replay on the next
open — or, alongside an escaped `\`/`"` in the same string, JSON glaze's
writer silently corrupts before it ever reaches disk. On open, the file is replayed
last-write-wins-per-id and rewritten in compacted form — this both bounds file
growth and heals a torn trailing line left by a crash mid-write, tolerating it
the same way `FileActionLog` does (a malformed *trailing* line is logged and
Expand Down
12 changes: 11 additions & 1 deletion docs/spec/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,17 @@ payload)` and `payload` is the base64url claims segment. The claims are a
| `roles` | Coarse-grained roles an authorization policy can key on. |

The claims are JSON (Glaze); adding application claims is compatible because
unknown fields are ignored on read.
unknown fields are ignored on read. `TokenIssuer::issue` writes the claims
with the same control-byte-escaping option `morph::wire::encode` uses
(`detail::EscapingWriteOpts`, duplicated locally in `session_auth.hpp` rather
than shared, to keep this header free of a `core/` dependency): a raw ASCII
control byte (0x00-0x1F) in `principal`/`roles` — most plausibly one embedded
in an application-supplied identity string — round-trips through
`TokenVerifier::verify` instead of producing JSON that either fails to decode
outright or, when the same string also holds an escaped `\`/`"`, is silently
corrupted before signing. `issue` throws `TokenIssuanceError` on encode
failure, though `SessionToken`'s flat shape (strings/integers only) makes that
not realistically reachable in practice.

#### Expiry is mandatory — `expiresAtMs == 0` is expired, not eternal

Expand Down
23 changes: 21 additions & 2 deletions docs/spec/session/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,28 @@ From the `ActionCall` the session travels backend-specifically:
authorizer, then (post-`authenticate`) installed via `ScopedContext` around
dispatch.

**Control envelopes carry the session too.** The above covers `execute`
envelopes, built once per call by `Bridge::executeVia`. Control envelopes —
`register`, `registerShared`, `attach`, `assign`, `deregister` — are built
directly inside the concrete backend (`registerModelWithContext`,
`registerModelShared`, `attachModel`, `assignPrimary`, `deregisterModel`),
which has no other route to the `Bridge`'s session except
`IBackend::setSession`: `Bridge` calls it on construction and on every
`setDefaultSession()`, and on the incoming backend during `switchBackend()`
(before any control envelope is built to re-register handlers), so a
wire-backed backend (`SimulatedRemoteBackend`, `SocketBackend`,
`QtWebSocketBackend`) always has the current session on hand to stamp onto
these envelopes too. This is what lets `RemoteServer::authorizeRegister` see
the caller's identity and the owner principal recorded at `register` time
reflect the registering session, which `authorizeInstance`'s ownership check
relies on (see [backend.md](../core/backend.md)'s "Session propagation to
control envelopes"). `LocalBackend` does not override `setSession`: the local
path never serialises a `Context` onto a wire envelope in the first place.

The bridge plumbing (`setDefaultSession`/`defaultSession`, the `ActionCall`
stamp) is specified in [bridge.md](../core/bridge.md); this spec covers only the
`Context` payload and its server-side handling.
stamp, `IBackend::setSession`) is specified in [bridge.md](../core/bridge.md)
and [backend.md](../core/backend.md); this spec covers only the `Context`
payload and its server-side handling.

## Principal — readable authorization state outside a dispatch

Expand Down
Loading
Loading