A sequence of stateful applications of gradually increasing complexity, each
anchored to existing open source software, designed to stress-test every
morph subsystem and find the framework's limits. Persistence is SQLite via
the Lightweight ORM throughout; clients are Qt (desktop + WASM), as in
bank.
Program scope (round-7 holistic review): the committed build is
rung 0 through rung 4 plus the no-app spikes below — that is where the
unproven seams live (first WASM-remote, first shared-over-socket, offline
replay, exactly-once, SQLite contention) and where reviews locate peak
findings-per-week. Rungs 5–8 are a design annex: their READMEs are
finished deliverables (requirements studies whose sharpest content the
spikes convert into CI at a fraction of construction cost); building any of
them is a separate decision taken after rung 4 with the
finding pipeline scoreboard in hand. Ledger (rung 5) is the
strongest candidate to build — the only annex rung with a genuinely
app-shaped core; forge's framework content ships as its load script against
synthetic models, and crm's as the extension-bag spike. The program's
product is findings fixed, not apps shipped — see
FINDINGS.md for what counts, triage, the fix budget, exit
criteria, and the demotion policy.
The no-app spikes (start immediately, in parallel with rungs 0–1; each files findings, none builds an app):
- Forms conformance suite — the round-5 D1–D8 test constructions (retag-vs-round, clamped-wire, nested enforcement, render-old/validate-new skew, locale, stale Choice, auto-fire, rules parity); needs no socket.
- Rational property/fuzz harness at ledger-realistic magnitudes (intermediate overflow, checked-arithmetic case).
- Journal payload-evolution spike — replay across a renamed/retyped action field; the versioning/migration design input for the annex.
- Extension-bag spike (7b) — one model with a runtime custom field through schema, forms, validation, journal; answers the crm endgame without the CRM.
- Forge load script — synthetic notification/poll models, 500–2,000 sockets, hardened configuration, epoch resync across restart.
Audience decision: the primary audience of every rung is morph's own regression suite and finding ledger. The single polished showcase is kanban (mid-ladder, every subsystem load-bearing, visually legible); every other rung takes rule 2's zero-styling literally, no guilt.
Each rung's folder contains a README describing what to implement, the open
source reference implementations to study, and the framework limits the rung
is expected to hit. Two binding companion documents:
IMPLEMENTATION.md — how the apps are written
(models are the application; minimal schema-driven GUIs; strong types only
in DTOs, std::string the sole plain type; persistence exclusively through
the Lightweight ORM; models 100% unit tested) — and
TESTING.md — how they are tested: every rung's GUI is
presenter-shaped and unit tested in both deployment modes (in-process
LocalBackend, and QtWebSocketBackend against an in-test RemoteServer
with N clients) plus a WASM-shaped single-thread mode, via the shared
examples/common/testkit.
Discipline rule: each rung names explicit design questions; they must be resolved in writing (in that rung's README) before the next rung starts — later rungs consume earlier answers (5 reuses 4's cascade-journaling answer, 7 reuses 4's board pieces, 8 reuses 2's job pattern and 3's event pattern).
| # | App | Anchor project(s) | New subsystems under stress |
|---|---|---|---|
| 1 | pastebin |
MicroBin | Full loop smoke test; journal semantics of state-mutating reads and expiry |
| 2 | bookmarks |
linkding | Multi-entity CRUD, bulk actions, sessions/authz, background jobs |
| 3 | polls |
Rallly | Shared instances, anonymous principals, undo, event polling |
| 4 | kanban |
Kanboard | Strand ordering under concurrency, RBAC, offline queue + replay, action cascades |
| 5* | ledger |
Firefly III, Actual Budget | Exact Rational arithmetic under invariants, multi-currency, sync-philosophy benchmark |
| 6 | lims |
SENAITE, InvenTree, ODK Central | Unit algebra, versioned schema-driven forms, offline entities with conflict detection |
| 7* | crm |
EspoCRM, Tryton, Frappe | Metadata-driven forms, dynamic logic, per-field authz; 7b (gated): runtime custom fields |
| 8* | forge |
Gogs, Gitea/Forgejo, GitLab architecture | Everything at once: orgs/permissions, notifications at scale, webhooks, out-of-protocol sidecars |
* = design annex: README is the deliverable; construction is a post-rung-4 decision (ledger under construction; lims built; forge → load script; crm → 7b spike).
Every subsystem is hit by at least two rungs:
- Per-model strands — 4 (concurrent board moves), 7 (multi-model lead conversion)
- Shared instances — 3, 4, 6, 8 (rung 3 is also the framework's first
ever
AllowShared-over-WebSocket coverage — a scope-heavy rung, like 1) - Journal / undo / audit — 1, 3, 4, 5, 6 (payload evolution), 7
- Offline queue + replay — 4, 5, 6, 7
- Forms + exact values / units — 5, 6, 7
- Sessions / authorization — 2, 3, 4, 5–6 (empty-principal refusal), 7, 8
- Application version skew (old client binary vs. new server, via
MORPH_CLIENT_ONLY) — 6 (owner), re-run at 8 across its own releases - Remote transport and its limits — all
These needs recur across the researched projects and deserve one framework-level answer each, introduced at a specific rung and reused afterwards:
- Background jobs (rung 2) — work triggered by an action but completing
later, mutating the model outside any client request. Correction from
verification: a typed in-process path exists today —
SimulatedRemoteBackendis a shipped public backend that routes through the complete server pipeline (authorizer, journal log provider, per-instance strand), so a server-side worker can be built as an internal client with a service principal. The genuine gap is narrower but real: no sanctioned seam, no defined service-principal convention, the simulated path is connection-unscoped (ConnectionId0), andhandleInlinerejectsexecute. Rung 2's design discussion starts from the internal-client option and decides whether a first-class framework seam is still warranted; rungs 4, 5, and 8 consume the answer. Time-scheduled jobs are a distinct shape with their own owner — rung 5 (recurring transactions): who ticks, on what thread, under what principal, journaled how. Forge's webhook retry loop assumes that answer exists. - Event polling (rung 3; rung 2's DoD includes a minimal
changes-since poll as its preview) — the Zulip-style
getEventsSince(lastEventId)action that substitutes for server push everywhere. See Zulip's events system. Two hard requirements from review: event sequences must survive instance destruction (shared instances die immediately at refcount zero — persist events or issue epoch tokens forcing full resync), and the client polling helper must bound every call (when this was written a rate-limited server dropped frames silently and morph had no client-side execute deadline, so the completion hung forever; both have since changed —Bridge::setExecuteDeadlinebounds the wait, and the transport now answers a rate-limited frame instead of dropping it). - File/blob attachments (rungs 4 and 8) — payloads that should not travel the JSON action protocol; side channel must share the authorizer's token discipline.
- Document generation (rung 5) — reports/invoices/statements as long-running submit-then-poll jobs with defined snapshot semantics.
- Exactly-once delivery (rung 4, re-tested with money in rung 5) — the
wire
Envelopehas no idempotency-key field and the server cannot recognize a replayed operation; a reply frame lost after commit means a retry double-applies. The answer (an op-id inside action payloads plus a server-side applied-ops ledger in the model) is established in rung 4 and reused everywhere writes are retried. - Journal payload evolution (rung 6, bites rungs 5 and 7 too) — replay decodes stored payloads with the current action structs; renaming a field silently drops recorded data. Versioned catalogs need per-entry schema pinning and a migration story.
Review verdict: the later rungs' claims oversell morph::journal, which is
an audit trail whose replay is exact only for pure, deterministic,
single-instance, in-memory models — not an event-sourcing engine. Known
hard limits: undoLast() returns a detached holder (no API installs it
into a live server registry, so in-place undo of a shared instance is not
possible today) and pops the newest entry regardless of principal;
cascaded mutations get no causal link to their trigger; there are no
cross-model transactions or correlated entries, so multi-model actions
(ConvertLead) cannot be replayed consistently; entries() re-reads the
whole file. Rung 1 must write the ladder-wide position: what the journal is
used for (audit, history rendering), what it is not (undo on shared
instances — use compensating actions; cross-model replay), and which
framework growth (replay-mode signaling, causal parent ids, per-principal
undo, indexed reads) the ladder should propose instead of assuming.
Verification found rung 1 had accreted ~twelve deliverables under a "smoke
test" label. The infrastructure is now split out as rung 0: the testkit
subset (pump.hpp, backend_rig.hpp, Qt-owning test main), the shared
presenter architecture (examples/common/gui), the ladder-tests CI job
with path-filtered MORPH_LADDER_RUNGS, and the WASM-remote spike (with
a written fallback if it bounces off framework work). Rung 1 is then the
pastebin app plus its own tests and design records.
Honest effort accounting (baseline: one "bank" = examples/bank, ≈9k LOC):
the full eight rungs would sum to ~19–25 bank-equivalents plus the
framework prerequisites — a multi-year solo effort, which is why the
committed scope is rungs 0–4 (+ spikes): ~8–10 bank-equivalents, a
6-month-scale solo horizon, and where adversarial review expects peak
findings-per-week. Deferral decisions recorded in the rung READMEs: kanban
defers automation rules and attachments to a "later" section (ledger needs
only the cascade decision, writable from a spike); the annex rungs keep
their internal gates (7a/7b, forge phase 3 per-item) for whenever they are
green-lit. The fault-injection wire proxy and the strand interleaver are
pulled forward to rung 0–1 (round-7: they outperform whole rungs on
finding yield; scheduling them at rung 4 delayed the program's
highest-value instruments behind three rungs of CRUD).
Parallelization: hard sequence 0 → 1 → 2 → 3 → 4; after rung 4's
written answers, 5, 6, and 7a are mutually independent (three
contributors can run them concurrently), and 8 phase 1 needs only 2's job
answer and 3's event pattern so it can start alongside 4. The coupling
point is examples/common — it needs an owner and an additive-only API
discipline after rung 3.
License hygiene (binding): morph is Apache-2.0; several anchors are AGPL/GPL (Rallly, Firefly III, EspoCRM, Tryton, SENAITE). Anchors are studied for requirements, data-model shapes, and behavior only — no source code, comments, or substantial expressive structure is ported from copyleft projects; all ladder implementation is original. Where a README says "model on"/"transliterate", it means the observable API surface and semantics, never the code.
Adversarial review found four items that invalidated rung definitions-of-done as written. All four have since shipped; they are recorded here because they explain why the rungs were sequenced as they were, not because anything is still blocked on them. Each is listed with where it landed, so a reader can check rather than take this section's word for it:
- Async shared/keyed attach for WASM (before rung 3's WASM story) —
registerModelShared/attachModelwere synchronous and nested an event loop, which aborts the page on the WASM main thread, andregisterModelAsynccovered only the plain path. Shipped:IBackend::registerModelSharedAsyncandIBackend::attachModelAsync(include/morph/core/backend.hpp), consumed byBridge::ensureBoundAsync/attachHandlerAsyncand implemented byQtWebSocketBackend. - Client-side execute deadline (before rung 3's polling helper) — no
timeout existed on a
Completion, so a black-holed server hung the client forever. Shipped:Bridge::setExecuteDeadline(include/morph/core/bridge.hpp), specified indocs/spec/core/completion.md. (This item also named rate-limiter drops as a cause; that half no longer applies either — the transport now answers a refused frame instead of dropping it.) - Injectable time source usable by remotely-constructed models (before
rung 1's expiry semantics) —
LogEntrytimestamps were hard-wired to the system clock and registry-constructed models are default-constructed, so tests needed a process-global now-provider convention. Shipped:setNowOverride/ScopedNowOverride(include/morph/util/datetime.hpp) — exactly that convention. - The fault-injection wire proxy (rung 0–1, pulled forward by the
round-7 review) — scriptable drop/delay/duplicate/kill between client
and server; without it the exactly-once, dead-letter, and
reconnect-mid-replay scenarios are demos, not CI tests.
Shipped:
examples/common/testkit/fault_proxy.hpp, with the deterministicstrand_interleaver.hppalongside it as promised, both with their own tests. See TESTING.md.
Also queued deliberately, with their status as of this writing: the offline
queue's depth bound has since shipped — IOfflineQueue::maxDepth() and
OfflineQueueFullError (include/morph/offline/offline_queue.hpp) enforce a
reject-newest policy (note the linear-scan/quadratic enqueue applies to
FileOfflineQueue only — SqliteOfflineQueue's key dedup is index-backed);
the SyncWorker's 5-attempt cap is still hard-coded and still dead-letters
legitimate writes after five flaky reconnects, though the surfacing hook rung 4
needs now exists (SyncWorker's DeadLetterSink constructor parameter), so
"surface dead-letters in the UI, not logs" is buildable rather than blocked;
and SQLITE_BUSY waits occupy pool threads (K writing models on a 2–4-thread pool can starve every strand,
fire executeTimeout, and still commit — the timeout-then-committed
double-apply is rung 4's sharpest data-corruption test).
Forms-subsystem gaps (from the round-5 deep review; owners in the
lims/crm/ledger READMEs): no sum types in the forms palette (the
quantity | belowLOD | aboveUDL result is a multi-field encoding glued by
x-rules, by design); rule vocabulary is closed single-node conditions (no
and/or/not — EspoCRM-class logic maps onto it or becomes a framework
proposal); schemas-as-data render old versions but validation always runs
against the current compiled struct; no per-caller schema shaping; nested
aggregates get schemas but no enforcement recursion and no child-table
renderer; no pre-decode wire validation seam (clamped Rationals reach
validate() as plausible numbers).
Explicit-submit mode is closed (it was the last entry on this list): an
action declaring static constexpr bool explicitSubmit = true makes
schemaJson<A>() emit the top-level x-submitMode: "explicit" key, and the
shipped DynamicForm.qml renders its own Submit button — gated on the form's
ready state — instead of auto-firing on validity
(docs/spec/forms/forms.md, "Explicit submit mode"). Adoption is per rung and
only polls has migrated so far, on all three of its schema-driven actions;
bookmarks, lims and pastebin still pair controller: null with a
hand-written Button at thirteen call sites between them.
- Security opt-in matrix (everything in
docs/spec/security.mddefaults fail-open): rung 1 deliberately tests the unhardened default (a test asserts the fail-open delta) and owns thehelloversion-negotiation test; rung 2 must exerciseauthorizeRegister+authorizeInstance(not justSigningAuthorizer); rung 3 runs its harness with the rate limiter ON (the polling helper's timeout is untested otherwise); rung 4's HTTP side channel reusesTokenVerifierand joins the fuzz corpus; rungs 5–6 get a CI leg withMORPH_REQUIRE_VETTED_HMAC=ON; rung 8 is the hardened-configuration demonstration — TLS, vetted HMAC, register/instance authorization, fullLimitPolicyand server bounds, negotiation — and its load script runs against that config (its README's non-goal is public exposure, not hardened configuration). - Observability: every rung's server installs a logging
morph::observe::MetricSink; rung 4 assertsqueueDepth/reconnect metrics in its offline tests; rung 8's load script consumesexecuteLatencyMs/executeInFlightand drives the drain viaRemoteServer::health()/beginShutdown(). - Persistence & migrations: all app persistence goes through the
Lightweight ORM per
IMPLEMENTATION.md— schema is owned byLIGHTWEIGHT_SQL_MIGRATIONdefinitions (bank's pattern), which is the migration story lims's replay-across-migration DoD presupposes. No rung writes database code itself. - Demo seeding: every rung ships a
--seedpath implemented on the testkit'saction_drivergenerators (deterministic demos, screenshots, Playwright). - Docs tax: framework prerequisites land in
include/morphand pay the full spec + Doxygen (WARN_AS_ERROR) + pinned-facts cost — budget +30–50% over code cost per item. Example code is exempt.
- No server-initiated push. Mitigated by the event-polling pattern (precedented: Zulip is long-poll only; Gitea's own UI polls; EspoCRM polls). Rung 8's many-clients-polling is the scale test — at 500–2,000 concurrent sockets at ~1 poll/s (the single Qt receive/reply thread is the ceiling, not the worker pool), including during a graceful drain. Sub-second collaborative text editing (Etherpad-class OT) is explicitly out of scope for the whole ladder — it is the one workload that genuinely requires push.
Completion<T>is not composable — long-running operations (merge, report generation) need a submit → job-id → poll-status idiom; nested execute-and-wait orchestration can deadlock the worker pool (rung 7 tests this deliberately).- Compiled C++ action types vs. runtime-defined entities — rung 7's endgame (Salesforce-style custom fields) decides how far served JSON-Schema forms can stretch without runtime type creation.
- Authorization is per-execute, attachments are ownerless — revoking a principal does not detach it from shared instances or cut off reads unless the authorizer distinguishes them (rungs 4 and 8 test revocation mid-session); a token expiring between authorize and authenticate dispatches with an empty principal, which regulatory rungs (5, 6) must refuse at the model.
- WASM ≠ desktop. The shipped WASM pattern is single-threaded and
local-only:
NetworkMonitor(probe thread) andSqliteOfflineQueue(filesystem) do not run in the browser, and a WASM client overQtWebSocketBackendhas never been exercised. Rung 1 proves WASM-remote; rung 4 scopes offline to desktop or builds browser-native equivalents (IndexedDB queue, online/offline events).