Skip to content
Open
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
13 changes: 11 additions & 2 deletions docs/spec/core/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,16 @@ with; actions with no validator are unaffected (`ready()` defaults to
`true`). No JSON is involved on this path, so there is no declared-precision
reconciliation step here (that only applies to decoded wire payloads); the
`Quantity` fields carry whatever precision the caller constructed them with.
The typed result is unwrapped from `std::shared_ptr<void>`
`Model::execute(*action)` itself is wrapped in a `try`/`catch
(const std::exception&)`: on success it records a journal `LogEntry` with
`outcome = Outcome::Succeeded` for loggable actions; on a throw it records
`outcome = Outcome::Failed` (`error = exc.what()`, `result` empty) for the
same actions and rethrows unchanged, so the exception still resolves the
`Completion` through `onError` exactly as before — the journal entry is a
side effect of the attempt, not a change to error propagation. Mirrors
`ActionDispatcher::registerAction`'s runner (`registry.md`) for remote
topologies. See [journal.md, "Outcome"](../journal/journal.md#logentry--one-recorded-action-execution)
for the full field/replay semantics. The typed result is unwrapped from `std::shared_ptr<void>`
into the final `Completion<R>` inside a `try`/`catch`: moving the result out of
the opaque `shared_ptr<void>` can throw (a throwing move/copy on `R`, or a bad
cast), and if that exception escaped the `.then` callback it would be swallowed
Expand Down Expand Up @@ -472,7 +481,7 @@ make teardown order-independent.)
| `registerHandler(binding)` | `void registerHandler(const shared_ptr<HandlerBinding>&)` | Pre-built binding. |
| `switchBackend` | `void switchBackend(unique_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. |
| `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 journal for loggable actions. 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`) are gated on the `_liveness` token, checked before either runs, so a completion resolving after `~Bridge()` skips them instead of touching the dangling `Bridge`. |
| `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`) are gated on the `_liveness` token, checked before either runs, so a completion resolving after `~Bridge()` skips them instead of touching the dangling `Bridge`. |
| `setDefaultSession` | `void setDefaultSession(session::Context)` | Installs default session context. |
| `defaultSession` | `session::Context defaultSession() const` | Returns snapshot of default session. |

Expand Down
13 changes: 10 additions & 3 deletions docs/spec/core/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,9 +380,16 @@ class ActionDispatcher {
`computedFields`; runs after precision reconciliation and before the
validator check, so the validator sees the authoritative computed value),
enforces `ActionValidator<Action>::ready(action)` (throwing `ValidationError`
on `false`, before `Model::execute` runs), executes via
`Model::execute(action)`, serialises the result, and records to the attached
action log when the action is loggable and a log is attached.
on `false`, before `Model::execute` runs), then calls `Model::execute(action)`
inside a `try`/`catch (const std::exception&)`: on success it serialises the
result and records a `LogEntry` with `outcome = Outcome::Succeeded` (when
loggable and a log is attached); on a throw it records `outcome =
Outcome::Failed` (`error = exc.what()`, `result` empty) for the same actions
and rethrows unchanged, so callers see the same exception as before — the
journal entry is a side effect, not a change to error propagation. Mirrors
`Bridge::executeVia`'s `localOp` (`bridge.md`) for `LocalBackend`. See
[journal.md, "Outcome"](../journal/journal.md#logentry--one-recorded-action-execution)
for the full field/replay semantics.
- `dispatch` looks up the runner and invokes it; throws `std::runtime_error` for
unknown pairs.
- `coalesce` returns the `ActionLogPolicy<Action>::coalesce` value for the pair;
Expand Down
58 changes: 42 additions & 16 deletions docs/spec/journal/journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,25 +48,34 @@ by `contextKey`; see [Attaching a log to remote instances](#attaching-a-log-to-r
## LogEntry — one recorded action execution

`LogEntry` is produced automatically by
`morph::model::detail::IModelHolder::recordIfAttached` after every successful
loggable action. Application and model code never construct or append these
directly.
`morph::model::detail::IModelHolder::recordIfAttached` after every loggable
action attempt — **both** a successful `Model::execute` and one that throws
(a validator rejection, a rejected write). Application and model code never
construct or append these directly.

| Field | Type | Meaning |
|---|---|---|
| `seq` | `uint64_t` | Monotonic order assigned by the sink on `append()`. Callers pass `0`. |
| `modelType` | `std::string` | String type-id of the model (`ModelTraits<M>::typeId()`). |
| `entityKey` | `std::string` | Stable identity of the model instance (e.g. account id), stamped from `attachActionLog()`. Empty if none was set. |
| `actionType` | `std::string` | String type-id of the action (`ActionTraits<A>::typeId()`). |
| `payload` | `std::string` | JSON-encoded request (`ActionTraits<A>::toJson`). |
| `result` | `std::string` | JSON-encoded result (`ActionTraits<A>::resultToJson`), captured after successful execution. |
| `payload` | `std::string` | JSON-encoded request (`ActionTraits<A>::toJson`). Always present, regardless of `outcome`. |
| `result` | `std::string` | JSON-encoded result (`ActionTraits<A>::resultToJson`). Populated when `outcome == Outcome::Succeeded`; empty when `Failed`. |
| `outcome` | `Outcome` | `Succeeded` or `Failed`. Defaults to `Succeeded` so a pre-existing on-disk entry (written before this field existed) decodes unchanged — an absent key is indistinguishable from an explicit `Succeeded`. Serialises as the string `"Succeeded"`/`"Failed"` via a `glz::meta<Outcome>` specialisation (the one exception to "`LogEntry` needs no `glz::meta`" below). |
| `error` | `std::string` | `std::exception::what()` from the exception that rejected the action. Empty unless `outcome == Outcome::Failed`. |
| `principal` | `std::string` | Auth principal from `morph::session::current()`, if any. Empty if unset. |
| `timestampMs` | `int64_t` | Wall-clock time, milliseconds since the Unix epoch. |
| `idempotencyKey` | `std::string` | Optional dedup token for outbox-relayed entries. Empty by default; ordinary auto-appended entries never set it. Mirrors `morph::offline::QueueItem::idempotencyKey`'s exact contract. See [Transactional outbox (opt-in)](#transactional-outbox-opt-in). |
| `v` | `std::uint32_t` | Line-format version this entry was written at. Defaults to `kLogFormatVersion`. See [Line-format version (`v`)](#line-format-version-v). |

`LogEntry` is a plain aggregate — Glaze reflects it without a `glz::meta`
specialisation, the same automatic reflection `BRIDGE_REGISTER_ACTION` relies on.
specialisation of its own, the same automatic reflection `BRIDGE_REGISTER_ACTION`
relies on. Both real `Model::execute()` call sites — `ActionDispatcher::registerAction`'s
runner (server/remote topologies) and `Bridge::executeVia`'s `localOp`
(`LocalBackend`) — wrap the call in a `try`/`catch (const std::exception&)`:
the `catch` records a `Failed` entry (`error = exc.what()`, `result` empty)
and rethrows unchanged, so the caller's error handling is unaffected — only
the journal gains an entry it previously lacked.

## Serialization

Expand Down Expand Up @@ -444,6 +453,13 @@ std::unique_ptr<IModelHolder> replay(
Throws `std::runtime_error` if `modelTypeId` or any entry's action type is
unregistered.

**`Failed` entries are skipped, not replayed.** A rejected/thrown action never
mutated model state, so there is nothing to reconstruct from it — and
re-dispatching it would likely throw the very same exception again, aborting
reconstruction. `replay()` filters out every entry with `outcome ==
Outcome::Failed` before dispatching; `Succeeded` entries dispatch exactly as
before.

**Reconstruction does not pollute the live audit trail.** `registry.create(...)`
(via `ModelFactory::create`) auto-attaches the process-wide default action log
to the new holder, exactly as for any ordinary model instance. `replay()`
Expand Down Expand Up @@ -665,7 +681,8 @@ All symbols live in `namespace morph::journal`.

| Symbol | Kind | Signature / Notes |
|---|---|---|
| `LogEntry` | struct | Flat aggregate: `seq`, `modelType`, `entityKey`, `actionType`, `payload`, `result`, `principal`, `timestampMs`, `idempotencyKey`, `v` (line-format version, default `kLogFormatVersion`). Glaze-reflected (no `glz::meta`). |
| `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`. |
| `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`. |
Expand Down Expand Up @@ -726,15 +743,24 @@ and `RemoteServer::setLogProvider(LogProvider)`, declared in `remote.hpp`. See

These hold for every sink and are relied on by `replay()`/`undoLast()`:

- **Only successful, loggable actions are recorded.** A `LogEntry` is produced
by `IModelHolder::recordIfAttached` *after* an action executes successfully.
Actions that throw (business-rule failures), drafts rejected by a validator
(`ActionValidator::validate`), and any action registered `Loggable::No`
(typically pure queries like `GetAccount`/`ListAccounts`) never appear in the
log. The log is a record of committed facts, not of attempts.
- **`result` reflects post-execution state.** `payload` is the request JSON;
`result` is captured only after success, so replaying `payload` re-derives an
equivalent `result` for a deterministic model.
- **Every loggable action attempt is recorded, tagged with its outcome.** A
`LogEntry` is produced by `IModelHolder::recordIfAttached` after both a
successful `Model::execute` (`outcome = Succeeded`) and one that throws — a
business-rule failure, a validator rejection (`ActionValidator::validate`)
— (`outcome = Failed`, `error` set, `result` empty). Any action registered
`Loggable::No` (typically pure queries like `GetAccount`/`ListAccounts`)
still never appears in the log either way. The log is a record of every
attempt against a loggable action, not only the ones that committed.
- **`result` reflects post-execution state; only `Succeeded` entries have one.**
`payload` is the request JSON, always present. `result` is captured only on
success, so replaying `payload` re-derives an equivalent `result` for a
deterministic model. A `Failed` entry has no `result` to derive — see
`replay()`, next.
- **`replay()`/`undoLast()` skip `Failed` entries.** A failed attempt never
mutated model state, so there is nothing to reconstruct from it — and
re-dispatching it would likely throw the same exception again, aborting
reconstruction. `replay()` filters `outcome == Outcome::Failed` entries out
before dispatching; `Succeeded` entries replay exactly as before.
- **`seq` is sink-local and re-stamped on every forward.** Each sink's
`append()` overwrites `entry.seq` with its own `++_nextSeq`, ignoring any
incoming value. When `SessionLog::checkpoint()` forwards entries to a durable
Expand Down
57 changes: 41 additions & 16 deletions include/morph/core/bridge.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -697,26 +697,51 @@ class Bridge {
::morph::model::ActionTraits<Action>::typeId()};
}
auto& model = holder.template into<Model>();
auto result = std::make_shared<R>(model.execute(*sharedAction));
// Local mode has no client/server split, so this is the same execution
// site `ActionDispatcher::registerAction`'s runner is for remote modes
// (registry.hpp) — see that overload's doc comment for the full story.
if constexpr (::morph::model::detail::actionLoggable<Action>() == ::morph::model::Loggable::Yes) {
if (holder.hasActionLog()) {
// entityKey/principal/timestampMs are filled in by recordIfAttached.
holder.recordIfAttached(::morph::journal::LogEntry{
.seq = 0,
.modelType = std::string{::morph::model::ModelTraits<Model>::typeId()},
.entityKey = {},
.actionType = std::string{::morph::model::ActionTraits<Action>::typeId()},
.payload = ::morph::model::ActionTraits<Action>::toJson(*sharedAction),
.result = ::morph::model::ActionTraits<Action>::resultToJson(*result),
.principal = {},
.timestampMs = 0,
});
// (registry.hpp) — see that overload's doc comment for the full story,
// including why both the success and failure paths below record a
// journal entry (a rejected/throwing execute must not leave the audit
// trail silent) and why the exception is rethrown unchanged either way.
try {
auto result = std::make_shared<R>(model.execute(*sharedAction));
if constexpr (::morph::model::detail::actionLoggable<Action>() == ::morph::model::Loggable::Yes) {
if (holder.hasActionLog()) {
// entityKey/principal/timestampMs are filled in by recordIfAttached.
holder.recordIfAttached(::morph::journal::LogEntry{
.seq = 0,
.modelType = std::string{::morph::model::ModelTraits<Model>::typeId()},
.entityKey = {},
.actionType = std::string{::morph::model::ActionTraits<Action>::typeId()},
.payload = ::morph::model::ActionTraits<Action>::toJson(*sharedAction),
.result = ::morph::model::ActionTraits<Action>::resultToJson(*result),
.outcome = ::morph::journal::Outcome::Succeeded,
.error = {},
.principal = {},
.timestampMs = 0,
});
}
}
return result;
} catch (const std::exception& exc [[maybe_unused]]) {
if constexpr (::morph::model::detail::actionLoggable<Action>() == ::morph::model::Loggable::Yes) {
if (holder.hasActionLog()) {
holder.recordIfAttached(::morph::journal::LogEntry{
.seq = 0,
.modelType = std::string{::morph::model::ModelTraits<Model>::typeId()},
.entityKey = {},
.actionType = std::string{::morph::model::ActionTraits<Action>::typeId()},
.payload = ::morph::model::ActionTraits<Action>::toJson(*sharedAction),
.result = {},
.outcome = ::morph::journal::Outcome::Failed,
.error = exc.what(),
.principal = {},
.timestampMs = 0,
});
}
}
throw;
}
return result;
};
{
std::scoped_lock const lock{_sessionMtx};
Expand Down
Loading
Loading