pastebin — rung 1 of the application ladder
Status: shipped — every rung-1 task is complete; see Definition of done for what that does and does not mean (the native stack is verified end to end; the WASM client is written and CI-gated but has never been compiled here). A minimal pastebin: create a text snippet, share its URL, let it expire or burn after N reads. The smallest complete morph application — one entity, one model, SQLite, Qt WASM client.
Reading this to learn morph, not to work on the rung?
docs/GETTING-STARTED.mdwalks this application layer by layer as a tutorial. This README is the rung's own design record — its resolved design questions, its findings and its definition of done — and assumes the framework is already familiar.
# One-time configure (Qt 6.5+, an ODBC SQLite3 driver, MORPH_BUILD_FORMS_QML
# for the schema-driven create form):
cmake -S . -B build -G Ninja \
-DMORPH_BUILD_QT=ON -DMORPH_BUILD_FORMS_QML=ON \
-DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=pastebin
# Server (owns the database, the action journal and the expiry sweep):
PASTEBIN_DB="DRIVER=SQLite3;Database=pastebin.db;Timeout=5000" \
PASTEBIN_PORT=8765 ./build/examples/pastebin/ladder_pastebin_server
# Desktop client, either deployment mode:
./build/examples/pastebin/ladder_pastebin_gui # in-process
./build/examples/pastebin/ladder_pastebin_gui --server ws://127.0.0.1:8765The browser client is the same program with a different main()
(gui_wasm/main_wasm.cpp), built only in an Emscripten configure — which
additionally needs -DMORPH_CLIENT_ONLY=ON, since a WASM client names its
model type but must not link the model's ODBC-backed bodies
(docs/spec/core/registry.md; morph_add_rung() fails the configure with that
explanation if the option is missing). Its server url is baked in at build time
via -DMORPH_LADDER_PASTEBIN_WASM_SERVER_URL=ws://host:port. The exact
configure line CI uses is .github/workflows/wasm-ladder.yml.
Scope note (delivery + verification reviews): review rounds had piled
ladder-wide infrastructure onto this rung until it stopped being small. That
infrastructure is now rung 0, delivered before the pastebin app: the
testkit subset (pump.hpp, backend_rig.hpp, db_fixture.hpp, Qt-owning
test main), examples/common/gui (AppContext + Presenter base), the
ladder-tests CI job, and the WASM-remote spike — the first-ever
WASM + QtWebSocketBackend run, which requires asyncRegistrationEnabled = true (opt-in, off by default) and the setConnectHandler pattern instead
of waitForConnected() (which hangs the page on WASM), with a written
fallback plan if it bounces off framework work. Rung 1 proper is the app
below plus its design records. Deferred from rung 1: the convergence
assertion (its poll()/lastEventId() hooks exist only from rung 3) and
the full hostile-content corpus suite (start with a representative subset).
- MicroBin (Rust, Actix,
BSD-3-Clause, ~4k LOC) — the anchor. Small enough to read end-to-end in an
afternoon; its single
Pastastruct is the data model. Supports SQLite or a flat JSON file behind a two-backend storage abstraction — directly analogous to morph's in-memory vs. SQLite-persisted split. - PrivateBin — studied and rejected as anchor: its zero-knowledge design makes the server a dumb ciphertext store, exercising none of the typed-model machinery. Worth a look only for its burn-after-read UX.
One model, PasteModel, keyed by paste id (animal-name ids like MicroBin's
are a nice touch), with actions:
CreatePaste { content, syntax, expiresAt, burnAfterReads, isPrivate }→PasteIdGetPaste { id }→PasteView— this is the interesting one: reading incrementsread_countand may delete the paste (burn-after-reads), so a read is a write.EditPaste,DeletePaste— plain mutations for editable pastes.ListPastes {}→ recent public pastes (pagination via cursor field).
Persistence: one Lightweight entity (PasteRecord) and one
LIGHTWEIGHT_SQL_MIGRATION, per ../IMPLEMENTATION.md
— fields modeled on MicroBin's Pasta (id, content, extension, private,
editable, created, expiration, last_read, read_count, burn_after_reads).
DTO fields follow the strong-type rule: PasteId, Timestamp, enum class visibility, a reads Quantity — std::string only for content and
extension.
Clients: Qt Widgets desktop client and the same code compiled to WASM
(follow ../bank/gui_wasm). Local and remote backends
must both work unchanged.
-
The full local/remote loop end-to-end on a fresh codebase (registration, strands, wire protocol, WASM build).
-
Journal: install
FileActionLogfrom day one. Design questions, resolved below (ladder discipline rule):- Is a state-mutating read an action? Resolved: yes —
GetPastestays the one client-visible, journaled action (defaultLoggable::Yes), not split. The recommended split (a pure, unloggedGetPasteplus an internally-journaledRecordReadmutation) turned out to be structurally unavailable:IModelHolder::recordIfAttached(include/morph/core/model.hpp:145) is called only by the two built-in dispatch runners, for the one action actually dispatched — there is no seam for a model to author a second, independentLogEntryfrom inside its ownexecute().Bridge::modelFactoryconstructor injection only reachesLocal-mode registration; morph has since grownModelRegistryFactory::registerModel<Model>(modelId, factory)(include/morph/core/registry.hpp) as the equivalent seam forSocket-mode's registry-constructed models, but this rung predates it and has not adopted it. Consequence, accepted and documented, not worked around: replayingGetPaste's entry re-runs the real burn/read-count logic against whatever row state exists at replay time — for a burn-after-read paste this can resurrect content the user was told was destroyed. This is the concrete, privacy-shaped example the journal-honesty position below generalizes from; it is not unique toGetPastein kind (replaying any DB-backed mutating action re-touches the live database — see that position) but it is the sharpest instance of it, so pastebin's UI must never expose a raw "undo"/"replay" affordance over the journal, only read-only history rendering. - How does expiry replay? Resolved: an explicit, journaled
ExpirePaste{id}action, dispatched by a periodic sweep that is a genuinely separate top-level call — not nested insideGetPaste's ownexecute().GetPaste's own atomic update (the burn-atomicity decision, below) already excludes an expired row from itsWHEREclause defensively, so correctness never depends on sweep timing — a client asking for an expired paste getsExpiredregardless of whether the sweep has reached that row yet. This is what makes a periodic sweep (a timer in the app-layer server bootstrap,src/app/, not model code — it is orchestration, not domain logic; typically every few seconds) both simpler than a per-request hook (RemoteServerhas no confirmed pre-dispatch interception seam to hang one on) and more complete than "on access" alone — it also reclaims pastes nobody ever requests again, which an on-access-only sweep would leave orphaned forever. The sweep queriesexpires_at_ms <= now()directly (a plain, unlogged read — not an action) and dispatchesExpirePaste{id}for each match through an internal client — aBridgeoverSimulatedRemoteBackend{*server}wrapping the app's own liveRemoteServer— a first-class client of the same server, not a bypass:SimulatedRemoteBackend::execute()callsRemoteServer::handle(), the exact path a real socket client's call takes (dispatchMessage→dispatchExecute→ActionDispatcher::dispatch), soExpirePasteis authorized, dispatched, and auto-journaled exactly like any client-issued action — no framework gap, no finding needed for this part.ExpirePaste's payload is just{id}(nevernow()), so replaying its entry is trivially deterministic regardless of when replay runs. Under this rung's fail-open default (no authorizer configured),RemoteServer::dispatchExecuteclears any claimed principal before the model sees it (authenticate()returnsnulloptby default), soExpirePaste'sLogEntry.principalreads empty — consistent with every other unauthenticated call this rung makes, not a gap. - The ladder-wide journal position paper. Resolved:
morph::journalis an audit trail — install it to answer "what happened, and when" (render read-only history;entries()+LogEntry.timestampMs/.principal/.outcome). It is not event-sourcing and not a safe reconstruction mechanism for any DB-backed model, pastebin'sPasteModelincluded:journal::replay()/SessionLog::undoLast()re-run the recorded action's realexecute()against a freshly created model instance — for an in-memory-only model that's an isolated sandbox, but for a model whose real state lives in Lightweight/SQLite (every ladder model to date), "fresh instance" only isolates the model object, not the database it immediately reopens and mutates again. Do not invokereplay()/undoLast()against a live install's database; they exist for offline forensic reconstruction (a copied-aside database file) or for models that are provably pure/in-memory, which no ladder rung has shipped yet. Framework growth this rung proposes instead of assuming: (1) a documented, opt-in "replay-safe" trait or marker distinguishing pure/in-memory models from DB-backed ones, soreplay()can refuse (or clearly warn) against the latter; (2) adopting the DI seam noted above, which would letGetPastebe split as originally hoped — not done in this rung.
- Is a state-mutating read an action? Resolved: yes —
-
Shared vs. unshared instance — the burn-atomicity decision. Resolved: SQL-atomicity, not a shared keyed instance.
PasteModelis registered plain (noBRIDGE_MODEL_KEY/AllowShared), matching bank'sNotificationModelshape, notAccountModel's. Burn-after-read atomicity comes from a conditionalUPDATE … WHERE read_count < burn_after_readsissued via Lightweight's raw-query facility (SqlStatement::Prepare/Execute) — the pre-enumerated sanctioned-escape-tier answer named in../IMPLEMENTATION.md§ sanctioned escape tier. As shipped this is the transaction-wrapped two-statement form, not the single-statement… RETURNING …one originally written here. The sqliteodbc driver acceptsUPDATE … RETURNING, applies it, and reports the returned column count, but the firstFetchRow()throws SQLSTATE 24000 "Invalid cursor state"; it never opens a cursor over the returned rows — filed upstream asLASTRADA-Software/Lightweight#545, tracked morph-side asLASTRADA-Software/morph#58.PasteModel::execute(const GetPaste&)therefore runs aSqlTransactionaround (1) the identical conditionalUPDATEminus itsRETURNINGclause, dispatched onNumRowsAffected(), and (2) an ordinaryDataMapperread-back by primary key. The atomicity argument is unchanged: it never rested onRETURNING, only on the guard living inside theUPDATE's ownWHERE, which SQLite evaluates and applies indivisibly under a write lock — of N clients racing for the last allowed read, exactly one gets a non-zero affected-row count. The transaction only keeps the read-back consistent with the write it reads back, and folds the burn-delete into the same commit. This also avoids the shared-instance option's WASM coupling: a shared keyed instance's firstGetPastewould drive the synchronous shared-attach path that aborts the page, pulling the async-shared-attach framework prerequisite forward from rung 3. Revisit sharing at rung 3, per the original recommendation. -
Lightweight behind a model at the smallest possible scale — the DTO ⇄ entity ⇄
DataMapperloop of../IMPLEMENTATION.mdproven on a one-entity schema before the bigger rungs depend on it. -
morph::async::CallbackScope— this rung is the ladder's first consumer, ingui_lib/paste_qml_bridges.hpp'sFormsBridge. ItssubmitIfValidhands the forms controller two callbacks that capturethisand emitreplyReceived; aCompletionalways resolves through the executor, and both shells own their bridges byunique_ptrinmain(), so a reply can land after the bridge is gone. The rung's two neighbours are covered by mechanisms that do not reach here —PastePresenterthroughPresenter::track()'sQPointerre-check,PasteBridgethrough Qt's signal/slot auto-disconnect — soFormsBridgetakes the framework's general answer: a last-declaredCallbackScopemember, with both arms wrapped in_callbacks.guard(...)(docs/spec/core/callback_scope.md).tests/test_paste_qml_bridges.cppdrives the window (destroy a bridge mid-submit, then let the reply land) and asserts the observable half — the dispatch completed after the bridge was gone. The suppression itself has no observable signature outside a sanitized build, which is stated there rather than dressed up: this was closed by construction, not after a measured crash.
Custom-GUI-element justification (../IMPLEMENTATION.md rule 2): at the
time this rung was built, the shipped morph::qt::forms::FormsControllerCore <Model> hardcoded its own Bridge/LocalBackend/executor internally, with
no way to compose it over AppContext's Bridge&/IExecutor* — a direct
conflict with ../TESTING.md's "never construct executors
or backends themselves" presenter rule, and silently untestable in Socket
mode. The shipped core's own (Bridge&, IExecutor*, schemasJson) constructor
now supports this composition directly, closing the gap framework-side;
gui_lib/paste_forms_controller.hpp still owns a thin controller of its own
(this rung predates that constructor).
Pastebin's GUI still renders exclusively from morph::forms::schemaJson<A>()
through the real MorphForms QML module (justification (b): pure glue, no
domain logic, no hand-rolled widget) — only the backend-wiring seam is
rung-owned: a thin controller exposing the same
schemaJson()/submitIfValid()/fetchOptions() surface, constructed over
the BridgeHandler<PasteModel> AppContext::onReady() hands it.
-
Hostile content round-trip: replay every input in
tests/fuzz/findings/as paste content (control bytes, broken UTF-8), both directions, both backends — the exact bug class fuzzing already caught once in the wire layer. -
Size-limit UX:
CreatePastebouncing off the server's message-size bound; the client renders a typed error. Typed error rendering debuts here, not rung 4. -
Duplicate create on retry: this rung ships the inverse of the requirement as originally written, and does so deliberately.
tests/test_paste_model.cpp's "Two CreatePaste calls with identical content mint two distinct pastes at this rung" asserts that two identical creates really do produce two pastes, because rung 1'sCreatePastecarries no op-id / idempotency-key field at all — there is nothing for a server to deduplicate on, andLADDER.mdscopes exactly-once delivery to rung 4. The case is written to fail loudly the day that discipline lands here, rather than letting a guarantee nobody implemented drift into the documentation.The blocker is the missing key, not missing tooling: the fault-injection proxy shipped at rung 0–1 as
examples/common/testkit/fault_proxy.hpp(LADDER.md's queued-work list records it as Shipped), it can drop exactly the reply frame of call k, andexamples/kanban/tests/test_kanban_offline.cppalready drives it. A genuine lost-reply-frame retry could therefore be staged against this rung today; it would simply mint the second paste, which is what the existing case already states in the cheaper way. Plus id-collision handling in the tiny animal-name keyspace. -
Expiry edges:
expiresAtin the past / at epoch / malformed (wire error, not clamped);GetPasteagainst an already-past-expiresAtrow before the periodic sweep has reached it (must still throwExpired— this is exactly what proves correctness doesn't depend on sweep timing); the periodic sweep firing between two pages of aListPastescursor walk. -
Security posture (per the LADDER matrix): this rung deliberately runs the unhardened fail-open default, with one test that asserts the delta (any client can register / execute against a learned id) as executable documentation of
docs/spec/security.md; it also owns thehelloprotocol-version-negotiation test — no example exercises negotiation today. -
Store-error branch coverage, per failure class, through the real schema — not through one failing driver.
db_fault_fixture.hpp'sSqlScopedLock-based contention cannot fault an ordinaryDataMappercall or the raw conditional update above (there is no injectable seam betweenDataMapperand the ODBC driver — seeexamples/TESTING.md's testkit section). This rung provokes two of the three failure classes for real instead:db_busy_fixture.hppholds a competing write transaction open on a second connection to force a genuineSQLITE_BUSY, and (for the raw conditional update specifically) a row already atread_count == burn_after_readsforces the zero-rows-affected branch. Constraint violations are not covered this way: no fixture forces a genuineUNIQUE/FK violation through the schema yet.IMPLEMENTATION.mdrule 5's per-line exclusion tag is reserved for whatever, after this, still provably can't be reached this way.
- Expiry sweeps are a time-driven background job — no client action
triggers them. Keep the rung-1 answer primitive (a plain periodic timer
in the app-layer bootstrap, dispatching through an internal client — see
the journal design decision above); the real background-job pattern
arrives in
bookmarks. - File attachments (MicroBin supports uploads) are out of scope — blobs through a JSON protocol are rung 4/8's problem.
-
Desktop client against local and remote backends.
ladder_pastebin_guiin both modes, driven manually against a realladder_pastebin_server(create → list → open → burn → delete) and by the offscreen QML engine-load smoke test in the suite. -
[~] WASM client, same client code.
gui_wasm/main_wasm.cppis the only file that differs from the desktop client: the presenters, the forms controller, the QML adapters (gui_lib/paste_qml_bridges.hpp), the schema document andgui/qml/Main.qmlare all shared verbatim — no shadow headers, no WASM variant of any model/DTO/QML file (../TESTING.md's hard requirement). It has never been compiled. No Emscripten toolchain existed in the environment it was authored in (emcmake: command not found), exactly as rung 0's own../common/wasm_spikerecords for the spike it rides on. What was verified locally: every shared translation unit plusmain_wasm.cppcompiles with__EMSCRIPTEN__andMORPH_CLIENT_ONLYdefined and the Lightweight/ODBC include paths removed — the client's include graph is genuinely persistence-free. What was not: the Qt for WebAssembly toolchain, the link, and the browser..github/workflows/wasm-ladder.ymlis the compile gate that will settle it. -
examples/common/testkitused throughout.BackendRig's Local/Simulated/Socket matrix,pump/pumpUntildiscipline,DbFixtureper test case,DbBusyFixturefor theSQLITE_BUSYbranches. -
Presenter-shaped GUI.
ladder_pastebin_gui_liblinksQt6::Coreonly (presenter rule 1);PastePresenteris tested in all three backend modes. -
Burn-after-read and expiry work, with the atomicity mechanism, its
RETURNINGlimitation and the ladder-wide journal position documented above. -
Model unit tests, following
../bank/testsconventions: 40 cases (grep -c '^TEST_CASE' tests/test_paste_model.cpp) covering the burn/expiry edges, theCreatePastevalidation rules, the hostile-content corpus replay, size limits, duplicate create, id collisions, the fail-open security delta andhelloversion negotiation. -
Findings filed rather than worked around — this rung's actual product: ten in total, spanning rung 0 through this rung. Eight have since been fixed framework-side; their gaps and fixes are described inline throughout this README and this rung's own source comments, not re-listed here. Two genuine, still-current limitations remain:
db_fault_fixture.hpp'sSqlScopedLock-based contention cannot fault an ordinaryDataMappercall (see "Store-error branch coverage" above), and the SQLite ODBC driver'sUPDATE ... RETURNING/SQLFetchcombination — see "Burn-atomicity" above and this rung'sLightweightissue tracking it upstream. Three framework/testkit bugs found on the way were fixed, not merely filed: JSON control-byte escaping in the action/result codecs, an executor-lifetime bug in the shared testkit, and — found by this rung's own QML-adapter suite —QtDrivenMainThreadExecutor::post()'s zero-delay drain timer capturing a barethis, which fired into freed storage oneGENERATEiteration later and aborted the process (examples/common/testkit/backend_rig.hpp, with a regression case intest_backend_rig.cpp). A fourth bug —Completion::onError's single-slot overwrite — was worked around at the time rather than fixed:gui/presenter.hpp'strack()folded a subclass's error-display callback and the busy-counter decrement into the one.onError()slotCompletionthen kept, instead of composing two separate calls.Completion'sonOk/onErrare now vectors of handlers (multiple.then()/.onError()attaches fan out instead of overwriting), sotrack()'s fold is no longer load-bearing — kept as-is since it still works and nothing forces the change. The sibling-writer half of the first bug above is also fixed: the same missing control-byte escaping injournal/action_log.hpp,offline/file_offline_queue.hppandsession/session_auth.hppwas closed framework-side.On the finding numbers this section used to cite. The ten findings above were filed under a flat global sequence that no longer exists.
../FINDINGS.mdnow namespaces every id by the rung that produced it (<ns>-NNN-<kebab-slug>.md) precisely because the flat sequence did not survive parallel branches — two disjoint series were allocated independently and both merged, so the same number came to mean different things on different branches.docs/findings/holds onlyr5-001…r5-004today, and the bare numbers this rung's prose used to carry (017,018,021,023,026) resolve to no file at all. They have been replaced throughout by a description of the gap and a pointer to the code that closes it, which is what a reader actually needs and what survives a renumbering. No finding file has been invented to make an old citation resolve.
- Findings triage complete.
../FINDINGS.md's "Rung exit criteria" makes a rung done when (1) its README's design questions are resolved in writing, (2) every named strain test exists — passing or filed as a finding, and (3) its findings are triaged (noopendispositions left). All three are now met: of the ten findings this rung owned or inherited, eight have since been fixed framework-side (the framework fixes are described inline throughout this README and this rung's own source comments, not re-listed here).db_fault_fixture.hpp's store-error coverage gap (see "Store-error branch coverage" above) is a genuine, still-current limitation, documented there and inexamples/TESTING.md/IMPLEMENTATION.mddirectly rather than as a standalone finding. The sqliteodbcRETURNING/SQLFetchgap (see "Burn-atomicity" above) is filed upstream againstLightweightand tracked morph-side asmorph#58— a genuine third-party ODBC driver limitation, not fixable in morph source. - The WASM client's verification status, above.
ladder-testsstill builds no GUI. That job's distro Qt is 6.4.2, below the 6.5 floorMORPH_BUILD_FORMS_QMLrequires, so it configures without the QML module, the desktop client or the smoke test —morph_add_rung()announces each skip rather than letting them vanish silently. Thelinux-all-featuresjob now enablesMORPH_BUILD_LADDERalongsideMORPH_BUILD_FORMS_QML(it already installs Qt 6.8), so that is where those targets are built and that test runs.- Registration timing.
PasteBridgeexposes aboundsignal (Presenter::trackBound(), backed byBridge::whenBound()) that settles once the registration round trip lands; both clients'Main.qmlgates its bootstraprefresh()on it instead of retrying on a timer.Remotemode still has no connect timeout, so a server that never answers leavesboundsimply never firing and the list pane empty with no terminal error. - Deferred by design: the convergence assertion (needs rung 3's
poll()/lastEventId()), the full hostile-content corpus (a representative subset ships), file attachments. Reply-frame loss is deferred for a different reason than it once was: the proxy that stages it (examples/common/testkit/fault_proxy.hpp) has shipped, so what is missing is theCreatePasteidempotency key that would make the retry's outcome differ from the ordinary double-create this rung already asserts — see "Duplicate create on retry" above.