Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
9da9d43
journal: add causalParentId + isReplaying() to LogEntry/replay()
Aug 18, 2026
6c2a135
docs: correct the DecimalPlaces floor-of-1 claim in LADDER.md and led…
Aug 19, 2026
54abd75
docs: add the ledger (rung 5) implementation design spec
Aug 19, 2026
fb4e333
testkit: action_driver.hpp -- SeededScript weighted generator + burst…
Aug 17, 2026
7f78e65
testkit: offline_rig.hpp -- scripted connectivity drop/revive
Aug 17, 2026
12db1b5
testkit: client_pool.hpp + convergence.hpp -- N-client convergence as…
Aug 17, 2026
d85fd34
docs: expand ledger's cherry-pick set from one commit to four (testki…
Aug 19, 2026
79713bc
docs: add the ledger (rung 5) implementation plan
Aug 19, 2026
8292c23
ledger: rung scaffolding (empty lib/server/tests targets)
Aug 19, 2026
325e9bc
ledger: strong ids, enums, error hierarchy
Aug 19, 2026
daf54ae
ledger: Currency unit system (dp=2 USD/EUR, dp=0 JPY/KRW)
Aug 19, 2026
2edc381
ledger: database.hpp + schema migrations for every table
Aug 19, 2026
8f5d58f
docs: correct ledger plan's Task 4 (db::setup signature, migration DDL)
Aug 19, 2026
830c05e
docs: correct ledger plan's Task 5 (setup() call, entity code, real API)
Aug 19, 2026
39311a0
ledger: entities for every table (Light::Field records)
Aug 19, 2026
dc08131
ledger: fix stale schema comment on causal_parent_id (nullable, not e…
Aug 19, 2026
16c0955
docs: correct ledger plan's Task 6 (AccountInfo.balance as plain Rati…
Aug 19, 2026
4ef192f
ledger: account_dto.hpp -- OpenAccount, GetLedger
Aug 19, 2026
f7e982f
docs: bulk-fix recurring ledger::db::setup() defect across Tasks 7-23
Aug 19, 2026
ee317b9
docs: correct ledger plan's Task 7 (keyed-model construction, BRIDGE_…
Aug 19, 2026
ab7ab86
docs: correct ledger plan's Tasks 8-10 (real code, seeded ledgers, ke…
Aug 19, 2026
527d794
ledger: LedgerModel skeleton -- OpenAccount, GetLedger
Aug 19, 2026
2b9be32
ledger: fix review findings on Task 7 (return-value coverage + balanc…
Aug 19, 2026
230aa1c
docs: correct ledger plan's Task 8 (real SqlTransaction/DateTime API,…
Aug 19, 2026
e894c33
ledger: StoreTransaction -- per-currency zero-sum invariant
Aug 19, 2026
4fc8ee8
docs: verify Task 10's ALTER TABLE migration against real Lightweight…
Aug 19, 2026
dbca676
ledger: foreign-amount pairs -- multi-currency, per-currency zero-sum…
Aug 19, 2026
d6ab9cf
docs: correct ledger plan's Task 11 (real Context/session API, Scoped…
Aug 19, 2026
31f267a
ledger: BudgetModel -- budgets, limits, in-model spent-so-far aggrega…
Aug 19, 2026
ea2e61e
ledger: fix Task 10 review findings -- untested date filter, missing …
Aug 20, 2026
02772d5
docs: insert Task 11a -- LedgerModel/BudgetModel self-journaling infr…
Aug 20, 2026
44ad762
ledger: refuse empty-principal writes at the model (design spec §11)
Aug 20, 2026
87da87b
ledger: self-journaling infrastructure on LedgerModel/BudgetModel (at…
Aug 20, 2026
baacdaf
docs: insert Task 11b (StoreTransaction exactly-once) + fully correct…
Aug 20, 2026
2765491
ledger: StoreTransaction exactly-once via opId + applied-ops ledger (…
Aug 20, 2026
4a30f10
ledger: RuleModel + cascade-journaling with causalParentId and rule-v…
Aug 20, 2026
1948410
ledger: Rational overflow fuzz test + two named framework findings (d…
Aug 20, 2026
c5994b5
ledger: fix Task 13 review findings C1/C2 -- real wire decode, fast e…
Aug 20, 2026
4eebe69
ledger: fix real signed-overflow UB in Task 13's binary-search fuzz test
Aug 20, 2026
207bac1
ledger: fix second real signed-overflow UB in Task 13's binary-search…
Aug 20, 2026
d5ba12c
docs: fully correct and concretize Task 14 (UndoTransaction)
Aug 20, 2026
3907f62
ledger: UndoTransaction -- compensating action, never undoLast()
Aug 20, 2026
9960726
docs: fully correct and concretize Task 15 (CSV import with dedup)
Aug 20, 2026
ba532c0
ledger: CSV import -- content-hash cross-import dedup, opId ledger po…
Aug 20, 2026
e711143
docs: fully correct and concretize Task 16 (reports: submit->poll, WA…
Aug 20, 2026
a479d31
ledger: reports -- submit->poll job idiom, snapshot semantics, model-…
Aug 20, 2026
38a84d6
ledger: fix Task 16 review finding -- leaked read transaction can sta…
Aug 20, 2026
3146628
ledger: fix Task 16 Minor review note -- comments stated fix history,…
Aug 20, 2026
ef61c53
docs: link filed GitHub issues to findings 001-003, add finding 004
Aug 20, 2026
c60b3e3
tests: opt morph_tests out of fastcache-cc, matching ledger's own exi…
Aug 20, 2026
4dc38a9
docs: commit a snapshot of the SDD progress ledger for rung-5 (ledger)
Aug 20, 2026
e5e2b25
ledger: fix CI-only -Wsign-compare error on GCC/Clang (Linux legs)
Aug 20, 2026
10bac3f
testkit: bring this rung's action-driver/convergence self-tests up to…
Yaraslaut Aug 21, 2026
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
12 changes: 12 additions & 0 deletions docs/findings/001-rational-checked-arithmetic-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 001
title: Rational has no checked-arithmetic mode; intermediate cross-terms can overflow before final results do
subsystem: units
severity: minor
source: ledger rung 5, design spec §7
disposition: open
test: tests/test_ledger_rational_fuzz.cpp
issue: https://github.com/LASTRADA-Software/morph/issues/130
---

At ledger-realistic magnitudes (dp=2 currencies, legs up to 10^9 minor units), Rational::operator+ summed over exactly 9,223,372,037 rows (INT64_MAX / 10^9, plus one) crosses int64_t's range, which is undefined behavior today (Rational's arithmetic operators are fixed-width, not saturating, and not exception-throwing by signature -- see include/morph/util/rational.hpp). This exact boundary is empirically confirmed by tests/test_ledger_rational_fuzz.cpp via a binary search that exercises the real Rational::operator+ at each candidate boundary (not a hand-computed estimate) -- see that test for the measurement method. A checked-arithmetic mode (an expected<Rational, Overflow>-returning operator+/- alongside the existing noexcept ones, or a debug-mode overflow assertion) would let a ledger-scale application detect this before committing corrupted state, rather than relying on the app never summing enough rows to hit the boundary in practice.
12 changes: 12 additions & 0 deletions docs/findings/002-rational-no-predecode-validation-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 002
title: No pre-decode validation seam for Rational -- setWire clamps hostile wire input to a plausible value instead of rejecting
subsystem: wire
severity: minor
source: ledger rung 5, design spec §7
disposition: open
test: examples/ledger/tests/test_ledger_model.cpp (clamped Rational leg test)
issue: https://github.com/LASTRADA-Software/morph/issues/131
---

A wire payload like {"num":5,"den":0,"dp":2} decodes via Rational::setWire into a plausible 5/1 rather than being rejected at decode time (see include/morph/util/rational.hpp's codec). Every dispatch path decodes before any model-level validate() runs, so an app has no seam to catch a clamped value as clamped -- it only ever sees an already-plausible Rational. Ledger's own zero-sum invariant happens to catch most clamped legs incidentally (a clamped value is unlikely to still sum to zero), but this is coincidental protection from a business rule, not a validation guarantee the framework provides. A pre-decode validation hook (reject rather than clamp, or a decode-time flag surfacing "this value was clamped") would close the gap for any app whose own invariants don't happen to catch it.
27 changes: 27 additions & 0 deletions docs/findings/003-no-model-level-background-job-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
id: 003
title: No framework seam for a model's own execute() to post background work and later update its own state
subsystem: core
severity: minor
source: ledger rung 5, design spec §9
disposition: open
test: spec-cited
issue: https://github.com/LASTRADA-Software/morph/issues/129
---

`morph::exec::IExecutor`/`ThreadPoolExecutor` (include/morph/core/
executor.hpp) has no usage anywhere inside a model's own `execute()` in
this codebase. Every existing "background job" (bookmarks' metadata-
fetch worker, examples/bookmarks/src/app/app.cpp) lives at the App/
Bridge/RemoteServer layer, re-entering the model as a fresh, ordinary,
fully-authorized client dispatch through a service-principal token --
not something a bare model with no App/Bridge/RemoteServer around it
can do. Ledger rung 5's report job (`SubmitReport`/`GetReportStatus`)
needed this and found no existing seam, so `LedgerModel` grew its own
`std::shared_ptr<morph::exec::IExecutor>` member as a local workaround.
A framework-level "background task from inside a model" primitive
(with a defined service-principal/session-propagation story for the
worker thread) would let future rungs avoid re-inventing this
per-model, and would let a future report job be tested with a
deferred/deterministic executor double instead of always spinning a
real thread pool.
16 changes: 16 additions & 0 deletions docs/findings/004-lightweight-pool-no-transaction-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
id: 004
title: Lightweight's DataMapperPool::Return performs no transaction cleanup -- a connection returned mid-transaction is silently inherited by the next caller
subsystem: backend
severity: minor
source: ledger rung 5, Task 16 review
disposition: open
test: examples/ledger/src/models/ledger_model.cpp (WalSnapshotGuard's own doc comment)
issue: https://github.com/LASTRADA-Software/Lightweight/issues/583
---

This finding is in the vendored `Lightweight` dependency, not morph itself -- filed against `LASTRADA-Software/Lightweight` (issue linked above), recorded here because it was discovered from inside a morph rung and shapes how that rung's code had to be written.

`Lightweight::DataMapperPool::Return` (and `~PooledDataMapper`) performs no transaction cleanup on a connection being returned to the pool: no `SQLEndTran`, no autocommit reset, no cursor close. A connection returned while a SQL transaction is still open (e.g. a raw `BEGIN DEFERRED`/`COMMIT` pair around a WAL-style read snapshot, per `IMPLEMENTATION.md` rule 4's escape tier) is silently handed to whichever unrelated caller acquires that connection next, which then blocks on its first write for the driver's own `busy_timeout` (60000ms in this codebase) before surfacing `SQLITE_BUSY`.

Discovered and fixed at the application layer in `ledger::LedgerModel::execute(SubmitReport)`'s background report-job worker: two real paths could leave a connection returned mid-transaction (the raw `BEGIN DEFERRED` itself throwing before any cleanup ran, or a recovery `COMMIT` on an exception-unwind path itself throwing and replacing the in-flight exception). Fixed with an RAII guard (`WalSnapshotGuard`) whose destructor always issues exactly one `COMMIT`, swallowing any failure, on every exit path including exception unwinding -- but this is a per-caller workaround for a gap in the pool's own connection-lifecycle contract, not a framework-level fix.
127 changes: 123 additions & 4 deletions docs/spec/journal/journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ by `contextKey`; see [Attaching a log to remote instances](#attaching-a-log-to-r
- [Rotation and retention](#rotation-and-retention)
- [SessionLog](#sessionlog)
- [replay()](#replay)
- [Causal links and replay-mode signaling](#causal-links-and-replay-mode-signaling)
- [Process-wide default log](#process-wide-default-log)
- [Attaching a log to remote instances](#attaching-a-log-to-remote-instances)
- [ScopedActionLog](#scopedactionlog)
Expand Down Expand Up @@ -67,6 +68,7 @@ construct or append these directly.
| `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). |
| `causalParentId` | `std::string` | Identity of the "trigger" entry that caused this entry to be recorded, or empty (the sentinel) if none. Set by application code that journals a cascaded mutation (e.g. an automation rule reacting to one recorded action by executing a further one). See [Causal links and replay-mode signaling](#causal-links-and-replay-mode-signaling). |

`LogEntry` is a plain aggregate — Glaze reflects it without a `glz::meta`
specialisation of its own, the same automatic reflection `BRIDGE_REGISTER_ACTION`
Expand Down Expand Up @@ -492,6 +494,106 @@ typically obtained by filtering a log with `entries(entityKey)` and by matching
one `replay()` call replays them all onto a single object and produces a
meaningless state. This is a precondition, not something `replay()` validates.

**`replay()` signals replay mode to executing code for its whole dispatch
loop.** See [Causal links and replay-mode signaling](#causal-links-and-replay-mode-signaling)
below.

## Causal links and replay-mode signaling

A cascaded mutation — one client action that causes further model mutations,
e.g. an automation rule reacting to "task moved to Done" by executing its own
further action — needs two things from the journal that a plain, uncascaded
action does not: a durable link back to what caused it, and a way for the
mutation that *produced* the cascade to avoid re-producing it a second time
when the trigger is replayed. Both are framework primitives, not app-specific
code; the first real consumer is `examples/kanban`'s automation-rules engine
(design spec `docs/superpowers/specs/2026-08-16-kanban-rung4-design.md` §9),
but neither piece is kanban-specific.

### `LogEntry::causalParentId`

A cascaded entry's `causalParentId` is set to the *triggering* entry's own
stable identity, so a reader (an activity-stream view, a replay-aware rules
engine) can recover "what caused this" without guessing from adjacency or
timing. Empty (the sentinel) means "not caused by another entry" — the
overwhelming majority of entries, including every entry recorded today, since
nothing in this codebase journals a cascade yet.

**Must not be a `LogEntry::seq` value.** `seq` is sink-local and re-stamped by
every sink's `append()` — and again by `SessionLog::checkpoint()` when
forwarding to a durable sink (see [Invariants](#invariants)) — so it is an
ordering key within one sink instance in one process run, not a stable,
cross-sink or cross-restart identifier. Application code that journals a
cascade must mint its own opaque/UUID-style identity for the trigger entry at
the point the trigger is created, independent of whatever `seq` any sink later
assigns it, and reuse that same identity as every cascaded entry's
`causalParentId`. `morph::journal` does not mint this identity itself — there
is no framework-side "trigger id" concept beyond the field that carries it;
the scheme for generating and threading it through is entirely the
application's (or, for kanban, the rules engine's) responsibility.

**Additive, per the [data-at-rest contract](#data-at-rest-contract).**
`causalParentId` is optional/defaulted exactly like `idempotencyKey` and every
other evolutionarily-added `LogEntry` field: an old payload recorded before
this field existed has no such key, and `fromJson`'s lenient decode falls back
to the empty default, so a pre-existing journal keeps decoding unchanged. This
does **not** bump `kLogFormatVersion` — the version bump is reserved for
*breaking* changes to the line format, and an additive, defaulted key is by
definition not one (see [Line-format version (`v`)](#line-format-version-v)).

### Replay-mode signaling: `isReplaying()`

`replay()` re-applies every recorded entry — trigger and cascade alike — in
their original recorded order. Without a way to tell "this dispatch is a
replay" apart from an ordinary live dispatch, a rules engine evaluating rules
against the replayed trigger would fire again and re-produce the cascade —
double-applying a mutation that is *also* being replayed from its own recorded
(cascade) entry. `morph::journal::isReplaying()` is the signal that lets
executing model/rule code tell the two cases apart:

```cpp
namespace morph::journal {
[[nodiscard]] bool isReplaying() noexcept;
}
```

Returns `true` while the calling thread is inside `replay()`'s dispatch loop,
`false` otherwise (including for every ordinary, non-replayed dispatch). A
rules engine (or any other model code that reacts to its own actions) checks
this before evaluating a rule; suppressing that evaluation during replay is
the actual mechanism that keeps a cascaded action's replay convergent — the
cascade's own recorded entry supplies the mutation, and rule evaluation
contributes nothing a second time.

**Mechanism: a thread-local flag plus an RAII scope guard**, the same shape
`morph::session::detail::tlsCurrent()`/`ScopedContext` already use to thread a
per-call `Context` through dispatch (`session.hpp`) — a thread-local slot
(`detail::tlsIsReplaying()`) and an RAII guard (`detail::ScopedReplayFlag`)
that sets it `true` on construction and restores the previous value on
destruction. `replay()` installs a `ScopedReplayFlag` immediately before its
dispatch loop, so the flag reads `true` for every entry that loop dispatches
and is restored to its prior value (`false`, for any ordinary top-level
caller) once `replay()` returns — it never leaks into dispatches that happen
after `replay()` completes. Nesting is well-defined for the same reason
`ScopedContext` is: a `replay()` call that itself triggers a nested `replay()`
leaves the flag `true` for the whole nested extent and restores the outer
call's value when the inner guard is destroyed.

**Why a thread-local, not a dispatcher parameter.** Threading a "replay mode"
boolean through `ActionDispatcher::dispatch(...)` and every `Model::execute`
signature would touch every registered action in the codebase, breaking the
existing `Model::execute(const Action&)` calling convention `BRIDGE_REGISTER_ACTION`
relies on. A thread-local, read via a free function, is additive: existing
`Model::execute` overloads compile and behave unchanged, and only code that
explicitly calls `isReplaying()` (the rules engine) observes anything new —
the same reasoning `session::current()` already established for `Context`.

**Scope: signals replay, not identity.** `isReplaying()` says nothing about
*which* entry is being replayed or *which* model instance — a rule reading it
combines it with the dispatched action's own fields (available inside
`Model::execute` the ordinary way) to decide what to suppress. There is no
`currentReplayEntry()` accessor; none of today's consumers need one.

## Process-wide default log

Every model instance created via `ModelFactory::create<Model>()` — every model
Expand Down Expand Up @@ -718,7 +820,7 @@ All symbols live in `namespace morph::journal`.

| Symbol | Kind | Signature / Notes |
|---|---|---|
| `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). |
| `LogEntry` | struct | Flat aggregate: `seq`, `modelType`, `entityKey`, `actionType`, `payload`, `result`, `outcome`, `error`, `principal`, `timestampMs`, `idempotencyKey`, `v` (line-format version, default `kLogFormatVersion`), `causalParentId` (identity of the triggering entry, empty by default). 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 with `detail::EscapingWriteOpts` (control-byte escaping). Throws `SerializationError`. |
Expand Down Expand Up @@ -752,7 +854,10 @@ and `RemoteServer::setLogProvider(LogProvider)`, declared in `remote.hpp`. See

| Symbol | Kind | Notes |
|---|---|---|
| `replay` | free function | `std::unique_ptr<IModelHolder> replay(modelTypeId, entries, registry, dispatcher)`. |
| `replay` | free function | `std::unique_ptr<IModelHolder> replay(modelTypeId, entries, registry, dispatcher)`. Sets `isReplaying()` to `true` for its dispatch loop — see below. |
| `isReplaying` | free function | `[[nodiscard]] bool isReplaying() noexcept` — `true` while the calling thread is inside `replay()`'s dispatch loop, `false` otherwise. See [Causal links and replay-mode signaling](#causal-links-and-replay-mode-signaling). |
| `detail::tlsIsReplaying` | inline function | `bool& tlsIsReplaying()` — thread-local slot backing `isReplaying()`. Not part of the public API; installed/restored only by `detail::ScopedReplayFlag`. |
| `detail::ScopedReplayFlag` | class | RAII: sets the thread-local replay flag `true`, restores the previous value on destruction. Copy/move deleted. |

## Design decisions

Expand All @@ -775,6 +880,8 @@ and `RemoteServer::setLogProvider(LogProvider)`, declared in `remote.hpp`. See
| `v` newer than `kLogFormatVersion` throws | **Fail loud, not guess** | A reader has no way to know the shape a future breaking change introduces; refusing to decode is safer than guessing a superset/subset shape. |
| `rotate()` reopens the active path regardless of rename outcome | **Never leave the log unusable** | A failed rename reopens the pre-rotation file in place (no data lost, rotation simply didn't happen); a successful rename reopens a fresh empty file. Either branch leaves `FileActionLog` in a valid, appendable state. |
| `setOutboxManaged` suppresses `recordIfAttached`, not `hasActionLog()` | **Two independent signals** | A store-backed model needs to stop the auto-append without losing "a log is attached" as a fact holders can still query — the suppression is a separate flag, not a side effect of detaching the log. |
| `causalParentId` is an opaque `std::string`, not a `seq` | **App-minted identity, independent of `seq`** | `seq` is sink-local and re-stamped on every forward (see Invariants below), so it cannot serve as a stable cross-sink/cross-restart causal key. Application code mints its own identity for the trigger entry at creation time and reuses it as the cascade entry's `causalParentId`. |
| Replay-mode signaling is a thread-local flag, not a dispatcher parameter | **Additive, mirrors `session::current()`** | Threading a "replay mode" parameter through `ActionDispatcher::dispatch`/every `Model::execute` signature would touch every registered action; a thread-local read via `isReplaying()` needs no signature change anywhere, the same reasoning that already justifies `morph::session::current()`'s shape for `Context`. |

## Invariants

Expand Down Expand Up @@ -806,7 +913,16 @@ These hold for every sink and are relied on by `replay()`/`undoLast()`:
cross-sink or cross-restart identifier. Use `entries()`' natural append order
for identity/ordering across sinks; do not persist or compare raw `seq`
values as keys. (`FileActionLog::seq` is likewise fresh per process — it does
not resume from the highest `seq` on disk.)
not resume from the highest `seq` on disk.) This is exactly why
`LogEntry::causalParentId` must never be a `seq` value — see [Causal links
and replay-mode signaling](#causal-links-and-replay-mode-signaling).
- **`isReplaying()` is `true` for every dispatch inside one `replay()` call,
and only there.** `replay()` installs `detail::ScopedReplayFlag` once,
before its dispatch loop, so the flag reads `true` for that loop's entire
extent (every entry it dispatches) and is restored to its prior value the
moment `replay()` returns — an ordinary, non-replayed dispatch always reads
`false`. `SessionLog::undoLast()` calls `replay()` internally, so the same
guarantee holds for it.
- **Reconstruction is single-instance.** `replay()` and `undoLast()` expect
entries already filtered to a single model instance — filter by `entityKey`
(via `entries(entityKey)`) and by `modelType` first. Feeding mixed instances
Expand Down Expand Up @@ -913,4 +1029,7 @@ Honest boundaries of the current design:
is live, and why recording is automatically server-side wherever a client/server
split exists.
- **`error_handling.md`** — `SerializationError` and the failure/validator-rejection
paths that explain *why* unsuccessful actions never reach the log.
paths that explain *why* unsuccessful actions never reach the log.
- **`session.md`** — `morph::session::detail::tlsCurrent()`/`ScopedContext`,
the thread-local-plus-RAII-guard shape `isReplaying()`/`detail::ScopedReplayFlag`
mirrors for signaling replay mode instead of a per-call `Context`.
Loading
Loading