bookmarks — rung 2 of the application ladder
Status: shipped — every rung-2 task is complete; see Definition of done for what that does and does not mean, and "The client, and its known gaps" for what the shipped client cannot reach (pagination is not reachable from the GUI, and a fetched title only appears on a manual refresh; the native stack is verified end to end, the WASM client is written and CI-gated but has never been compiled here). A multi-user bookmark manager: save URLs, tag them, search, bulk-edit, archive, share with other users. The first "small but real" app: several related entities, real authorization, and the first background jobs.
# One-time configure (Qt 6.5+, an ODBC SQLite3 driver, MORPH_BUILD_FORMS_QML
# for the schema-driven forms):
cmake -S . -B build -G Ninja \
-DMORPH_BUILD_QT=ON -DMORPH_BUILD_FORMS_QML=ON \
-DMORPH_BUILD_LADDER=ON -DMORPH_LADDER_RUNGS=bookmarks
# Server (owns the database, the signing secret, the action journal, the
# metadata-fetch worker and the outbox relay). The secret is required and has
# no default: it signs every token the server mints and verifies every token
# it is shown, so a built-in fallback would be a published signing key.
BOOKMARKS_TOKEN_SECRET="pick-something-real" \
BOOKMARKS_DB="DRIVER=SQLite3;Database=bookmarks.db;Timeout=5000" \
BOOKMARKS_PORT=8766 ./build/examples/bookmarks/ladder_bookmarks_server
# Desktop client, either deployment mode:
./build/examples/bookmarks/ladder_bookmarks_gui # in-process
./build/examples/bookmarks/ladder_bookmarks_gui --server ws://127.0.0.1:8766Sign in with any username (dev-mode login, no password — see
include/bookmarks/dto/auth_dto.hpp for exactly what that does and does not
mean). Run two clients with two usernames against one server to see the
isolated collections and the shared feed.
Local mode is deliberately the smaller deployment: it hosts the models in
the client process, so it journals nothing, runs no metadata worker and no
outbox relay, and — because LocalBackend runs no authorizer at all — is
single-user by construction. The two-user isolation this rung is about is
only meaningful against the server.
- linkding (Python/Django,
MIT, SQLite by default, ~11k LOC app + ~23k LOC tests) — the anchor.
Probably the cleanest small schema in its class (9 Django models in
bookmarks/models.py), a complete REST API, and an exceptional test suite to steal test cases from. - Shaarli (PHP, flat-file, no DB) — secondary reference: proof that single-user bookmarking needs no database at all; its whole-datastore-in-memory design is literally morph's in-process model. Good for the local-backend-only variant.
Models: BookmarkModel (per-user collection), TagModel, later
SharedFeedModel. Follow linkding's schema: Bookmark (url, title,
description, notes, unread, archived, timestamps), Tag, many-to-many
bookmark↔tag, UserProfile.
Actions, in build order:
- Bookmark CRUD + archive/unarchive + tag assignment.
- Search/list with filters (tag, unread, archived, text) and pagination.
- Bulk operations —
BulkEdit { ids, addTags, removeTags, archive }: the first multi-entity atomic action; all-or-nothing against SQLite. - Tag rename/merge (cascades across bookmarks).
- Netscape HTML import/export — large payload through the wire protocol;
measure where message-size bounds (
docs/spec/security.md) bite. - Sharing: mark bookmarks shared, other users read a merged shared feed.
-
Sessions & authorization for the first time: every action carries a
session::Context; anIAuthorizerscopes users to their own collections; shared feeds are the first cross-principal read. Per review, adopt real signed-token authentication here, not hand-waved principals: the shippedSigningAuthorizer+authenticate()hook (include/morph/session/session_auth.hpp,docs/spec/session/session.md) are essentially untested at app scale — more precisely than originally framed:examples/bank/tests/test_remote.cpp'sNoCloseAuthorizerauthenticates by trustingctx.principaloutright with no signature verification at all, and says so in its own comment. Bookmarks is the first rung to wire real signed-token auth end-to-end, not merely the first to touchIAuthorizer. This rung's server mints and verifies tokens withSigningAuthorizer's defaulthmacSha256MAC (notMORPH_REQUIRE_VETTED_HMAC's stricter injected-MAC mode — that flag is a hardened-deployment concern for a later rung to pick up; this one exercises the ordinary path).authorizeRegister/authorizeInstancewere intended to be exercised for real (see "Design decisions" below), withtests/test_policy_hardening.cpp'sOwnershipAuthorizeras the framework precedent for per-user instance ownership.authorizeInstanceis now genuinely reachable and enforcing — see the "Instance-level ownership is now real, but is not the layer that protects a user's data" bullet under "Design decisions" for exactly what it does and does not catch.authorizeRegisterremains unconditionally permissive by choice. What is wired end-to-end and genuinely exercised regardless is the part that matters most: signed tokens minted by the server, verified on every singleexecute, with the verified principal made authoritative before any model runs. The local backend genuinely never authorizes (LocalBackend::registerModel/bindModelconsult noIAuthorizeranywhere inbackend.hpp) — models re-checkContext::principalthemselves regardless of backend, per rule 1. -
The background-job pattern (this rung's framework-level deliverable): linkding auto-fetches title/favicon/preview after save (
bookmarks/services/tasks.py) — work triggered by an action that completes later and mutates the model outside any client request. Resolved: internal-client pattern, no new framework seam. A typed in-process path already exists and is sufficient —SimulatedRemoteBackendis a shipped public backend routing through the complete server pipeline (authorizer, journal log provider, per-instance strand).examples/pastebin/src/app/app.cpp'sApp/_sweepBridgealready proves the pattern working end-to-end (ashared_ptr-capturedBridgeHandlerkept alive across every dispatched call's.then()/.onError(), closing the real race a plain local handler would hit againstRemoteServer's async dispatch); this rung's metadata-fetch worker reuses that shape unchanged. One part of the original framing was overstated and is corrected here:handleInlinedoes reject"execute"(a real, documented restriction — its reply would write into a stack buffer already gone by the time the async reply lands), butSimulatedRemoteBackend::execute()never callshandleInline— it calls the async 2-argumenthandle(), so the rejection never fires for the internal-client path; it was never actually a blocker. Service-principal convention (defined here, for every later rung that reuses this pattern): the worker mints its own signed token via aTokenIssuersharing the server'sSigningAuthorizersecret, withprincipal = "system:metadata-fetcher", and attaches it to every call viaBridge::setDefaultSession(). Its calls then authenticate and authorize exactly like a real user's — fully auditable in the journal viasession::current()->principalinside the model — with zero framework changes. This rung's worker constructs its backend with the one-argumentSimulatedRemoteBackend(RemoteServer&), so its calls carryConnectionId 0— the server's unscoped sentinel — and nothing it registers is ever reclaimed bycloseConnection. That is this rung's choice, not a framework limitation:SimulatedRemoteBackendalso ships a(RemoteServer&, ConnectionId)constructor taking a scope fromRemoteServer::openConnection(), and a scoped worker's registrations would be reclaimed exactly as a dropped socket's are (examples/TESTING.md's closed-gap list, entry 5). The unscoped shape is kept for two reasons. It needs no reclamation to be correct: the worker's handlers are created per pass and released only once every dispatch that pass issued has settled, andApp's shutdown-drain contract — the same manual lifetime-ownership discipline rung 1 established, reused verbatim — is what ends the last of them. And a scope has nowhere correct to be closed here:closeConnectionreclaims every instance registered under it, so calling it from~App's body would run while_fetchBridgeand any outstanding pass are still alive — the same "model not found" racefetchMetadataOnce's own comment exists to close — while the point where it would be safe (after_fetchBridge's destruction) is not reachable from a destructor body at all. Scoping the worker is therefore a change to this class's teardown design, not a constructor swap.The GUI sees results on a later poll: this rung's DoD includes a minimal
GetChangesSincepoll action as the event-pattern preview (rung 3 formalizes the full event-queue design) — there is no existing polling/event-sequencing precedent anywhere in the framework to reuse; this rung builds it from aChangesCursorquery (a millisecond timestamp paired with a same-instant id tie-break, not a bareTimestamp— the fix for the boundary case a timestamp-only cursor can silently drop), deliberately minimal otherwise. The action exists and is tested; the shipped client does not dispatch it (see the client-gaps list). -
Journal: tag renames and bulk edits give the first multi-row entries. Two separate decisions, both resolved: (a) store/log atomicity — split by blast radius.
BulkEdit(BookmarkModel) andMergeTags(TagModel) are the two actions that mutate more than one row and need the journal entry to be atomic with the mutation, so each writes its ownbookmark_outboxrow inside the sameSqlTransactionas the mutation, andjournal::OutboxRelaydrains that table into the durableIActionLogin a separate pass (App::relayOutboxOnce). A crash mid-mutation can therefore never leave the store and the journal disagreeing about a partially-applied bulk change.examples/concepts/journal_and_outbox.cppis the worked pattern this follows, and remains the repo's only other consumer ofOutboxRelay.The opt-out mechanism is per action, not per instance: both are registered
Loggable::No(models/bookmark_model.hpp,models/tag_model.hpp), which suppresses the framework's own auto-append for exactly those two action types so it cannot double-log alongside the model's outbox write. The framework's other opt-out,IModelHolder::setOutboxManaged(true), is not used here and would be the wrong tool: it is a property of a model instance, so it silencesrecordIfAttachedfor every action that instance serves (include/morph/core/model.hpp) — including the single-row CRUD that deliberately keeps the default. Per-actionLoggable::Nois the finer instrument, and it is the one this split needs.RenameTagis not outbox-managed and is registered plain-loggable: it updates one row (src/models/tag_model.cpp) and writes no outbox row at all. It sits with the plain single-row bookmark CRUD (create/edit/archive/delete), which keeps the framework's default two-independent-write behavior — the same choice rung 1 made forPasteModel, but only ever implicitly; here it is explicit: a crash between the store commit and the journal append can lose that one action's journal entry, but can never corrupt the store, and a single-row loss carries none of a partially-applied bulk edit's ambiguity. (b) Undo: no generic undo, consistent with the ladder-wide positionLADDER.md's "Journal honesty" section already recorded at rung 1 —journal::undoLast()returns a detached holder with no API to reinstall it into a live server registry, so in-place undo of a shared instance is not possible today, full stop.DeleteBookmarkis a hard delete with no compensating action (mirroring rung 1'sDeletePaste);unarchiveis an ordinary domain action that happens to reversearchivein effect, not journal-level undo, and needed no special framework support to write.
Three further decisions this rung's README named or implied but didn't yet resolve in writing:
- Model topology and the shared feed — corrected after deeper research
(see below), superseding the paragraph this bullet originally had.
BookmarkModel,TagModel, andSharedFeedModelare all registered plain — noBRIDGE_MODEL_KEY/AllowSharedanywhere in this rung. The original plan was framework-sharedinstances "keyed by principal," with ownership enforced throughauthorizeInstance; that design does not work.RemoteServer::acquireSharedInstance()(include/morph/core/remote.hpp) files every shared instance with_instances.insertShared(fresh, std::move(holder), std::move(dirKey)), leaving that call'sownerargument defaulted to empty — "shared instances are ownerless, by design", as the comment on it says. Its own doc comment says why: a shared instance's owner is always recorded empty, specifically soauthorizeInstance'sownerPrincipal == ctx.principalcheck does not reject the second, third, ... client who attaches to it. That makes the ownership check a no-op for anyAllowSharedmodel — exactly backwards from what per-user ownership needs. The mechanism that actually records a real owner is plain (non-shared) registration: theregisterbranch ofRemoteServer::dispatchMessage()stamps_instances.insertPrivate(mid, std::move(holder), std::move(env.session.principal))from the verified, authenticated caller. SoBookmarkModel/TagModelare registered plain, exactly likepastebin::PasteModel— each client's ownregistercall gets its own fresh instance, andauthorizeInstancegenuinely denies a different principal from touching that specific instance. Nothing about "one collection per user" is lost by dropping the shared-instance framing: a model instance carries no meaningful in-memory state here — all real state is the database, partitioned by anownerPrincipalcolumn — so every registration by the same user, from any device, reads and writes the identical rows regardless of how many separate instances exist for them.SharedFeedModelis also registered plain, for a different reason:AllowSharedrequires a keyed action (BRIDGE_MODEL_KEY/ActionKeyTraits) to converge multiple clients onto the same instance, machinery built for genuine multi-client convergence that buys nothing here — everySharedFeedModelinstance reads the identicalWHERE shared = 1rows regardless of how many instances exist, so there is nothing to converge. OneBookmarksAuthorizer(ownerPrincipal.empty() || ownerPrincipal == ctx.principal, theOwnershipAuthorizershape fromtests/test_policy_hardening.cpp) covers all three model types without branching: plain-registeredBookmarkModel/TagModelget a real, non-empty owner check;SharedFeedModel's ownexecute()never usesownerPrincipalto filter anything, so the same check being trivially permissive there is harmless — its actual protection isauthorizeRegister's "must be authenticated" gate. Ownership is enforced twice regardless, per rule 1: server-side via the authorizer, and again inside the model itself againstContext::principal, since the local backend enforces neither. - Instance-level ownership is now real, but is not the layer that
protects a user's data.
register/attach/assign/deregisterenvelopes carry the caller's authenticated session, soRemoteServerrecords a real, non-empty owner for each ofBookmarkModel/TagModel's plain-registered instances, andauthorizeInstance's ownership comparison genuinely denies a different principal'sexecute/deregisternaming that instance'smodelIddirectly — confirmed empirically (a test authorizer loggedctx.principal/ownerPrincipalfor both alice's and mallory's own instances during development).authorizeRegisterstays unconditionally permissive, by choice rather than necessity (see its own doc comment). What this does not do is protect one user's row from another's, and it never could, fixed or not:BridgeHandler<Model>(this rung's only shipped client) never names another connection'smodelId— each client only ever dispatches through its own registered instance — so a normal client's cross-user access attempt (GetBookmark{id}naming another user's row through the caller's own, legitimately-owned instance) never touchesauthorizeInstance's check at all; it would pass regardless. That is caught only by the model's own row-level re-check (tests/test_bookmark_model.cpp's "denied by the model's own ownership re-check ... not by authorizeInstance" case, confirmed by the propagated error message:"bookmark belongs to a different principal", notauthorizeInstance's"unauthorized"). Everyexecutealso still goes throughSigningAuthorizer::authorize()(a real signature and expiry check, on a token an unauthenticated caller cannot produce), andRemoteServerstill overwritesContext::principalwith the verified identity before the model runs. Three layers in total, each catching a different thing: token validity (authorize), instance ownership (authorizeInstance, real but narrow), and row ownership (the model itself, the one that actually matters for user isolation). The one action that deliberately does not scope by row owner,RecordMetadata, checks in its own body that the caller is the metadata-fetch service principal —authorizeInstancecannot express that either, since the worker's own instance is exactly what it is authorized to use — andAuthModelrefuses to mint a token in the reservedsystem:namespace, so that authority cannot be requested from outside. - Bookmark↔tag many-to-many. Lightweight's
DataMappershipsHasManyThrough<ReferencedRecord, ThroughRecord>(.../DataMapper/HasManyThrough.hpp), but it cannot be used as an embedded member onBookmarkRecord/TagRecordhere:DataMapper::Update()'s non-reflection path callsIsModified()on every record member viaEnumerateRecordMembers, and neitherHasMany<T>norHasManyThrough<T,U>declares that method — a record type that embeds either fails to compile the momentUpdate()is instantiated for it (verified directly against Lightweight's vendoredDataMapper.hpp/Description.hpp; independently confirmed byexamples/bank/include/bank/db/account_entity.hpp's own doc comment making the identical argument forHasMany). So:BookmarkRecord/TagRecordcarry zero relation-typed members. The many-to-many is still a real junction entity,BookmarkTagRecord(BelongsTothe bookmark,BelongsTothe tag, its own surrogate primary key) — but tag reads go through a plainQuery<BookmarkTagRecord>().Where(...)call in the model, never an embedded relation field.BookmarkTagRecorditself never needsUpdate()(onlyCreate/delete), so this doesn't affect it. Tag assignment/removal is a directCreate/delete ofBookmarkTagRecordrows by the model — this was always true regardless of theHasManyThroughquestion, since its ownLoaderis read-only (count/all/each, noAdd/Remove) — consistent withHasMany's own documented limitations elsewhere in the ladder (rule 4's "Lightweight's own documented idioms" clause). No new sanctioned-escape-tier entry is needed: a plainQuery<>()call is ordinaryDataMapperusage, not an escape. - Bulk-write mechanics.
BulkEdit's per-item mutations are heterogeneous (some ids get tags added, others removed, some archived) —SqlStatement:: ExecuteBatchonly fits a homogeneous single-statement batch, so it is not the right tool here.BulkEdit(and tag rename/merge) use N individual statements inside oneLightweight::SqlTransaction{mapper().Connection(), SqlTransactionMode::ROLLBACK}, the same all-or-nothing patternPasteModel::execute(GetPaste)/execute(EditPaste)already proved out in rung 1 — any unhandled throw mid-batch rolls back automatically, andtransaction.Commit()is reached only once every item in the batch has applied.
Every decision above was verified against real source before being written
here, not assumed from a doc comment: SigningAuthorizer,
SimulatedRemoteBackend, OutboxRelay, and OwnershipAuthorizer were all
read in include/morph/ and tests/ directly, and HasManyThrough's
read-only Loader shape was confirmed against Lightweight's own vendored
source and test entities, alongside the examples/pastebin/
examples/concepts precedents cited inline above.
- Background fetches racing user edits on the same bookmark — strand serialization should make this safe; write the test that proves it.
- Cross-model rename race:
TagModelrenames a tag while aBookmarkModelBulkEditadds the old name — two strands, no cross-instance transactions, and the strand cannot fix it. The test documents where consistency becomes app responsibility. - Local mode has no authorization at all (the local backend never
authorizes): the first multi-user rung must demonstrate this with a test
and document the mitigation — models re-checking
Context::principalthemselves, perdocs/spec/security.md. - Unicode tags: NFC/NFD and case — SQLite
NOCASEis ASCII-only, so the C++ comparison, the SQLite unique index, and the GUI display can disagree; pick a normalization point and test it. - Favicon/preview blobs: store paths in SQLite, bytes on disk; do not send them through the action protocol.
- Import of thousands of bookmarks: chunked actions; a connection drop between chunks must resume without duplicating (idempotency keys) and without a phantom half-import in the journal.
- Two users on the remote backend with isolated collections and a working
shared feed; authorization enforced server-side, not by the client. This
originally read "specifically via the shipped
authorizeRegisterandauthorizeInstancehooks … not only model-level checks", on the reasoning that leaving them untested here means they stay untested forever. Task 12 exercised them against a realRemoteServerand found that neither hook could see a caller's identity, becauseregisterenvelopes carried no session — filed as a finding, since fixed: envelopes now carry the caller's authenticated session, andauthorizeInstanceis genuinely enforcing for plain-registered instances (see "Instance-level ownership is now real" above). The criterion reads: server-side enforcement viaSigningAuthorizer::authorize()on every action,authorizeInstance's now-real instance-ownership check, and the models' own verified-principal, row-level scoping — three layers, with the last doing the work that actually protects one user's data from another's, since instance-level ownership alone was never the layer that could. - Metadata auto-fetch demonstrably running as a background job: bookmark
appears immediately; title/favicon arrive later, and the minimal
GetChangesSincepoll action (the rung-3 preview) is what a client asks for them with. This criterion is met at the model and presenter level, whereGetChangesSinceis implemented, tested and exposed. The shipped client does not dispatch it — see the client-gaps list below. - Bulk edit is atomic under injected mid-batch failure.
- The background-job design record (internal-client vs. framework seam, service principal, journaling of job mutations) written in this README.
Everything below is a real gap, stated here rather than left for a reader to discover. Gaps in the client specifically have their own list further down; these are the domain- and test-coverage ones.
- Unicode tag normalization is unaddressed. "Expected strain points"
above asks this rung to pick a normalization point (NFC/NFD, case) and
test it. It does not: tag names are compared and indexed as raw bytes, so
a
cafétyped as NFC and one typed as NFD are two different tags, and SQLite's ASCII-onlyNOCASEdoes not close it. No test covers this. - Chunked import is correct but never tested at scale. Idempotency per
opIdis tested, and a chunk overkMaxImportChunkBytesis refused withTooLarge— deliberately not byImportBookmarks::validate()itself, since every real dispatch path (Bridge::executeVia,RemoteServer) consultsvalidate()beforeBookmarkModel::executeis ever reached, so avalidate()-level rejection would always surface as the untypedValidationError, never asTooLarge. The distinction is only observable in-process (a direct call, orLocal/LocalSingleThreaddispatch throughBridge): overSocket/remote transport,RemoteServerencodes every server-side exception as an opaquewire::makeErr(exc.what())string and the client reconstructs a genericstd::runtime_error, discarding the original type — a framework-wide property of every model's typed errors, not specific to this rung. Nothing here imports thousands of bookmarks across many chunks, and no test drops a connection mid-sequence. - The transport's own message-size bound is not measured by this rung.
kMaxImportChunkBytesis set "well under" it, but that relationship is asserted, not verified: there is no bookmarks equivalent of pastebin's "An oversizedCreatePasteis refused by the transport" test. If the transport bound ever drops below 64 KiB, this rung's own chunk limit stops being the one that bites and nothing here would notice. is_unreadis write-once at creation — nothing ever clears it. Every bookmark is created unread and no action (there is noMarkRead/MarkUnread) ever flips the column. SoReadFilter::ReadOnlyalways returns an empty page, andReadFilter::UnreadOnlyis behaviorally identical toReadFilter::Any. The column, the enum and the filter are all wired end to end and would work the moment a mutating action exists; there simply isn't one.- The GUI never leaves the first page.
BookmarkBridge::refresh()discards thenextCursorevery list/feed response carries, and no QML binding asks for a further page. The shipped client therefore shows at most the first ~20 bookmarks (and the first ~20 shared-feed entries) with no way to reach the rest. Pagination is fully implemented and tested at the model level — the keyset cursor works — it is only the client that does not use it.
The desktop client (gui/, gui_lib/) is schema-driven throughout
(../IMPLEMENTATION.md rule 2): Login, CreateBookmark, EditBookmark,
ImportBookmarks, RenameTag and MergeTags all render from
morph::forms::schemaJson<A>() through the shipped MorphForms
DynamicForm, including the login screen — there is no hand-built username
field, and no hand-built input widget anywhere. Each of the six declares
explicitSubmit = true, so its schema carries "x-submitMode": "explicit"
and the renderer supplies its own gated Submit button
(docs/spec/forms/forms.md, "Explicit submit mode"): there is no hand-built
submit button either, and every form is bound to the live controller. The one
non-form input on the whole screen is the per-row selection checkbox, which
types nothing.
gui_lib/bookmark_qml_bridges.hpp's FormsBridge composes
morph::qt::forms::MultiModelFormsControllerCore<NoSharing, AuthModel, BookmarkModel, TagModel> directly, over the Bridge&/IExecutor*
AppContext::onReady() hands it — no rung-owned routing controller sits
between them; the shipped core routes each action-type string to whichever of
the three form-serving models owns it, via a plain existence check over
ActionExecuteRegistry rather than a hand-written table.
One piece of glue carries its own written justification, per rule 2's "(b) pure glue with no domain logic" clause:
gui::FormsBridge::onLoginSucceeded— installs the token the server returned as the sharedBridge's default session, so every subsequent action carries it. Infrastructure wiring, not business logic: it decides nothing, and both the token and the principal it announces are the server's, never the client's claim.
Known gaps:
- Array fields are typed as strings, whatever the schema's
itemssays.DynamicFormrenders a JSONarrayfield with a dedicated comma-separated-with-validation control (src/qt/forms/qml/DynamicForm.qml'sisArraydescriptor andfieldJsonLiteral/arrayJsonLiteral, covered bysrc/qt/forms/tests/tst_DynamicFormArrayField.qml) and encodes it as a genuine JSON array literal, but every entry in that array is a JSON string. Array-of-string is therefore the fully supported case and array-of-anything-else is not (docs/spec/forms/forms.md, "Array fields").CreateBookmark::tags/EditBookmark::tagsarestd::vector<std::string>, reaching the renderer as{"type":"array","items":{"type":"string"}}— exactly the supported shape — so tagging works from the create and edit forms with no special-casing inBookmarkListView.qml, which renders every field the schema declares. Not independently re-verified end to end against this rung's ownMORPH_BUILD_FORMS_QMLbuild, but the schema shape is identical to the one the framework test above exercises and this rung's forms apply no exclusion. BulkEditis not a form, and the reason is the typing half above rather than a missing control: its one required member isstd::vector<BookmarkId>, whose schema is{"type":"array","items":{"$ref":"#/$defs/BookmarkId"}}withBookmarkIddefined as{"type":["integer","null"], …}. Typing1, 2would submit["1","2"], an array of strings that does not decode back intostd::vector<BookmarkId>. The GUI drives it from the list's own multi-selection throughBookmarkBridge::bulkArchiveinstead, where no typing is involved.- The shipped client never polls for background-job results. The metadata
worker fills a title in some seconds after a bookmark is created, and
GetChangesSinceis the action a client asks for that with — implemented inBookmarkModel, exposed asBookmarkPresenter::getChangesSince, and tested at both levels.BookmarkBridgedeliberately does not relay it (gui_lib/bookmark_qml_bridges.hpp) and no QML binding asks for it, so a fetched title appears only on the next manual Refresh. There is noTimeranywhere in this rung's QML. - Six model instances per client, not four.
app.cpp'skMaxLiveModelscomment budgets "roughly one instance per model type it uses (four in this rung)". The shipped client registers six: the forms controller owns anAuthModel, aBookmarkModeland aTagModelhandler, and the three presenters own aBookmarkModel, aTagModeland aSharedFeedModelhandler.BridgeHandler<Model>is a template over one model type and both classes take(Bridge&, IExecutor*)by presenter rule 2, so sharing one handler between them is not expressible today. At the 256 cap that is ~42 concurrent clients rather than ~64. - Registration timing.
BookmarkListViewrequests its three bootstrap listings onComponent.onCompleted, and the login submit is dispatched whenever the user clicks. A call made before its handler's registration round trip lands waits for it and is dispatched once it does, or is rejected if registration fails.Remotemode has no connect timeout, so a server that never answers leaves those calls pending. - No
--seed.LADDER.mdasks every rung for one; this rung's server ships none, deliberately — seesrc/server/main.cpp's file comment for the argument (seeding by direct model call would needmorph::session::detail::ScopedContext, the exact detail-namespace reach the testkit reaches into four detail namespaces finding objects to (recorded on theapplication-ladderbranch; never filed in this repository), and the internal-client alternative is rung 4'saction_driverwork). Demo data is created through the client. - The offscreen QML smoke test proves loading, not behavior — see
tests/test_gui_qml_smoke.cpp's own header comment for exactly what it does and does not cover. The behavioral half is the presenter suites plus the manual end-to-end run.
Both were invisible to every test that existed, because every test drove the models or the presenters directly and none drove the client:
- Login was unreachable over a real server.
SigningAuthorizer::authorize()verifiesContext::tokenon everyexecuteand rejects when there is none — including forLogin, the only way to obtain a token. A fresh client goterr "unauthorized"for everything it could possibly send.BookmarksAuthorizer::authorizenow carves out exactlyAuthModel/Loginand nothing else; see its doc comment for why that gives nothing away, andtests/test_bookmarks_authorizer.cppfor the unit-level and over-the-wire regression tests. CreateBookmark::titlewas schema-required. It was missing fromoptionalFields, so the generated create form refused to submit without a title — making it impossible to create from the GUI the very title-less bookmark the background metadata fetch exists to complete, which is one of this rung's own definition-of-done items.titleis now optional in bothCreateBookmarkandEditBookmark, matching whatvalidate()and the member's own doc comment always said.