ladder: shared infrastructure, docs, and framework prerequisites (rung 0) - #88
Merged
Conversation
…g 0)
Split out of the application-ladder branch (236 files, 32.8k insertions)
into its own reviewable PR, per LADDER.md's own framing: rung 0 has no
app of its own, only the shared foundation every later rung builds on.
This is that foundation, with no rung's application code (pastebin,
bookmarks, polls) included -- those land as their own follow-up PRs.
Contents:
- CI/CMake: .github/workflows/ci.yml, wasm-ladder.yml, cmake/morph_add_rung.cmake,
cmake/compiler_options.cmake, codecov.yml, top-level CMakeLists.txt/vcpkg.json
changes needed to build an opt-in ladder (MORPH_BUILD_LADDER) alongside
the existing example/test targets without disturbing them.
- Cross-rung docs: examples/LADDER.md, IMPLEMENTATION.md, TESTING.md,
FINDINGS.md -- the two binding companion documents (how apps are
written, how they're tested) every rung is held to, plus the finding
pipeline's scoreboard/triage process.
- Design-annex README stubs: examples/{crm,forge,kanban,ledger,lims}/README.md
-- rungs 4-8, each a finished requirements study; building any of them
is a separate decision taken after rung 4, per LADDER.md's own program
scope note. No code, docs only.
- Shared examples/common: the GUI presenter base (presenter.hpp, non-
template QObject bookkeeping every rung's presenters build on),
AppContext (LocalBackend/QtWebSocketBackend/WASM mode switch), the
injectable clock, and the full testkit (BackendRig, DbFixture family,
fault-injection proxy, strand interleaver, event poller) every rung's
test suite depends on -- all covered by its own unit tests
(ladder_common_tests).
- Framework prerequisites the rungs needed and that landed here first:
registry.hpp/remote.hpp changes, exercised by tests/test_quantity_forms.cpp
and the new tests/test_remote_execute_ordering.cpp.
- The lint-gate scripts/tests already merged via #85 (check_test_type_names.sh
+ its test fixtures), carried forward from the earlier merge into this
branch.
Verified standalone: configures and builds with -DMORPH_BUILD_LADDER=ON
-DMORPH_BUILD_QT=ON against master's examples/CMakeLists.txt loop, which
already tolerates rung 1+ directories not existing yet ("no rung exists
yet at rung 0" is a real, working code path, not a placeholder). Full
suite passes standalone: morph_tests (9773 assertions), morph_qt_tests
(496 assertions), ladder_common_tests (212 assertions against its SQLite
default). Spec-citation, banned-terminology, and test-type-name lints
all pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
This was referenced Aug 14, 2026
Merged
The foundation PR (this branch) deliberately ships no rung code -- the per-rung PRs (pastebin/bookmarks/polls) land separately, based on this branch. wasm-ladder.yml's "build every rung's WASM client by name" tripwire step hardcoded all three targets unconditionally, so the job failed here with "ninja: error: unknown target 'ladder_pastebin_gui_wasm'" -- there is no examples/pastebin on this branch to produce that target. Fix: each named target now only builds if its rung's directory exists in the checkout. This is the same "no rung exists yet" case morph_add_rung.cmake's own header comment already documents as a silent, expected outcome (a rung with no gui_wasm/ yet simply gets no ladder_<rung>_gui_wasm target) -- not the regression this tripwire exists to catch. Once a rung's directory is present (every other branch/PR, including once the per-rung PRs merge here), the tripwire is unchanged: morph_add_rung() skipping a rung's gui_wasm target for any other reason (missing gui_lib, missing QML module, MORPH_BUILD_FORMS_QML off) still fails the job. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PR #88's patch coverage was 94.31% against a 97.37% target, almost entirely traced to remote.hpp's new AllowShared/keyed-model attach-path logic (registry.hpp's own two changed lines are fully exercised already by every registered model's toJson()/resultToJson() call). Of the 31 uncovered lines there, three clusters were reachable deterministically through the public API, with no timing race needed at the test level: - attachExistingLocked's "connection closed mid-attach" reply (remote.hpp:647-650): closeConnection() and handle() are both synchronous under _regMtx, so calling closeConnection(cid) before an attach carrying that same cid presents noteScopeAttachLocked()'s precondition directly, every run. - "assign"'s authenticate()-succeeds branch (remote.hpp:1002): every existing assign test leaves the caller unauthenticated; a session carrying a principal against an authenticator that echoes it back exercises the other side of the same if/else. - SimulatedRemoteBackend::assignPrimary's early-return guard (remote.hpp:1724-1725): a plain black-box call with an empty primary or a zero ModelId, confirmed by checking the key was never filed. The remaining ~24 lines are genuine concurrency races (two calls racing the same locked section within microseconds -- "a concurrent request for the same key may have won the race," maxLiveModels/maxInFlightExecutes overshoot windows, ticket-release edge cases) or pure defensive assertions unreachable through the public API (a malformed reply body RemoteServer itself never produces). These are left as the source's own comments already frame them -- deliberately rare, self-documenting branches -- rather than forcing fragile, precisely-timed tests to chase the last few points of patch coverage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow-up to a2ec27e: closes four more of the previously-uncovered lines (31 -> conservatively down to the low 20s once codecov re-ingests), using the same slow-first idiom test_remote_execute_ordering.cpp established (a factory/authorizer that sleeps once, exchange-guarded, on the first call it sees) to force a deterministic winner/loser instead of hoping real thread scheduling happens to interleave correctly: - acquireSharedInstance's second attachExistingLocked check (remote.hpp): two attaches to the same not-yet-existing shared key, where the first dispatched is reliably still in its slow _registry.create() when the second's fast create+insert completes, forcing the first to find the directory already populated on its own re-check. - The private register path's maxLiveModels authoritative re-test (remote.hpp): same idiom, applied to two plain registers racing the cap instead of a shared-key insert. - applyAssignLocked's early-return guard (remote.hpp): no timing needed here at all -- an empty primary or an unregistered modelId is a deterministic precondition. Every existing assign test in this file assigns a real, just-created mid onto a non-empty primary, so this branch (env.primary.empty() || !_models.contains(mid)) had simply never been exercised. One more attempt -- maxInFlightExecutes' compare-exchange loop losing a race -- needed a second iteration to stop being flaky: the first version used CS_SquareModel (an instantly-completing action), which let the winning call's entire execute -- including the decrement back out of _inFlightExecutes -- finish before the losing call's own CAS check ever ran, so both calls could observe an empty slot and both would succeed; measured at roughly 40% failure across 20 repeated local runs. Switched to CS_SlowModel (blocks in execute() until released), which keeps the winner's slot genuinely held until the loser's CAS check has run; confirmed clean across 20 repeated local runs (and the full suite, 3x) after the fix. Line 747 (acquireSharedInstance's own final "ok" reply, immediately after the closing brace of a fully-covered locked block) is left as an llvm-cov region-boundary artifact, not a real gap: every line inside that block is covered, this test file's existing "two connections sharing a key reach one instance" case already reaches it, and it is not reachable through any different path this session could add coverage against. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… race test CI's clang-tsan leg caught a real bug in 98bb28f's new test: it polled replyA.env.kind/replyB.env.kind directly from the main thread while a pool worker thread was concurrently writing WaitReply::env in the reply callback. WaitReply's only synchronization is its `ready` atomic (release-store on write, meant to be acquire-loaded before env is read -- see its own doc comment/every other use in this file, all gated by .await()); reading .env before observing .ready == true is exactly the race TSan flagged, not a false positive. Fix: poll .ready.load() instead of .env.kind directly, and only read .env after confirming .ready is true (short-circuited via &&), matching the synchronization every other WaitReply-based assertion in this file already relies on via .await(). Verified 20x locally after the fix, plus a clean full-suite run; the other two new race tests in the same commit already followed this pattern correctly (each gates its .env access behind .await()) and needed no change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
remote.hpp's uncovered-line count is down to 11 as of dfed898 (from the original 31): every existing executeTimeout test either lets the timeout actually fire, or lets a normal action complete well inside the budget -- none combine a *throwing* action with executeTimeout configured, so the catch block's own _timeoutScheduler->cancel() call had never run. CsSquareFail (already registered in this file's csEnv()) throws synchronously; pairing it with executeTimeout confirms the "err"/message reaches the caller (not a stale "timeout" landing later) and that the server is still healthy afterward. Remaining uncovered lines in remote.hpp, left as-is: - 716-717: releaseScopedLocked's call inside the create-path race branch, for the specific sub-case where the *losing* side of that race is itself re-pointing from an existing instance (releaseCurrent != 0) -- a three-actor setup (existing instance + two racing attaches) on top of the slow-factory technique already used for 715/718/719; not attempted this round. - 747: acquireSharedInstance's own final "ok" reply, immediately after a fully-covered locked block -- confirmed (previous commit) to be an llvm-cov region-boundary artifact, not a real gap. - 1467-1468, 1489-1490: awaitExecuteTurn/releaseExecuteTicket's defensive checks -- reachable only by a genuine multi-ticket race (the former) or a state the codebase's own invariants say cannot happen at all (the latter, per its own "should not happen" comment). - 1743-1744: SimulatedRemoteBackend::listInstances' decode-failure throw -- RemoteServer's own reply is always well-formed JSON, so nothing reachable through the public API can trigger this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
trackBound() (presenter.hpp) had no test at all: every ProbePresenter test in this file drives track()'s success/error paths, none touch bound()/trackBound(). Two cases: - Local mode's handler is already bound by construction, so whenBound()'s Completion settles on the first event-loop turn; asserts bound() fires exactly once, observed via a real Qt connection. - trackBound()'s QPointer<Presenter> guard exists specifically for a presenter destroyed before whenBound()'s posted completion resolves -- constructs one in a nested scope, destroys it, then pumps to let the still-pending completion actually run. Nothing to assert beyond "does not crash" (a real use-after-free here would be caught by the ASan/UBSan CI legs, not by a plain logic assertion). Also investigated (not fixed) three further apparent gaps in this PR's diff, each traced to the same root cause -- llvm-cov's per-instantiation line-coverage reporting not merging cleanly across many distinct template instantiations of the same header-only function: - examples/common/testkit/pump.hpp: pumpUntil<Pred>'s timeout branch (lines 74-79) and awaitQt's deadline-throw (112-113) each show up to 15+ times in the report, once per distinct lambda-type instantiation across the whole test suite. Both branches are genuinely tested (test_pump.cpp's own "pumpUntil returns false on timeout without hanging" and "awaitQt timeout does not leave dangling references" cases) -- every *other* call site's own instantiation just never happens to time out, which is the correct, intended behavior for a passing test, not a coverage gap to chase. - examples/common/gui/presenter.hpp lines 23-25/63 (Q_OBJECT, constructor, trackBound's signature) and testkit/fault_proxy.hpp lines 109-134 (FaultProxy's own Q_OBJECT/declarations): moc-adjacent declaration lines reporting 0 hits despite their out-of-line definitions/call sites being exercised elsewhere -- consistent with the same class of report-level artifact already confirmed twice this session (remote.hpp:747, backend_rig.hpp's client<T>() throw) via CI logs proving the relevant tests actually ran and passed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslaut
pushed a commit
that referenced
this pull request
Aug 15, 2026
Split out of application-ladder (originally bundled with bookmarks/polls and the shared foundation in #41) into its own PR against the rung-0 foundation (#88). Includes the pool-migration and Unicode-content fixes folded in during review of the combined branch: - PasteModel acquires connections from Lightweight::GlobalDataMapperPool() per execute() call rather than holding one for its own lifetime (WithMapper removed). - content is Light::SqlMaxDynamicWideString, not std::string/SqlText -- both of those are char-based and would render as VARCHAR(MAX) (a single-byte-collation column) on the SQL Server backend this same test suite can target via ODBC_CONNECTION_STRING; SqlMaxDynamicWideString's wchar_t-based storage self-declares NVarchar, giving real Unicode columns on every backend. Verified standalone against the ladder-foundation base: configures and builds with -DMORPH_LADDER_RUNGS=pastebin and no other rung present. Full suite passes: 834 assertions in 51 test cases (SQLite default). Spec-citation and test-type-name lints clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslaut
added a commit
that referenced
this pull request
Aug 15, 2026
* ladder: rung 1 -- pastebin Split out of application-ladder (originally bundled with bookmarks/polls and the shared foundation in #41) into its own PR against the rung-0 foundation (#88). Includes the pool-migration and Unicode-content fixes folded in during review of the combined branch: - PasteModel acquires connections from Lightweight::GlobalDataMapperPool() per execute() call rather than holding one for its own lifetime (WithMapper removed). - content is Light::SqlMaxDynamicWideString, not std::string/SqlText -- both of those are char-based and would render as VARCHAR(MAX) (a single-byte-collation column) on the SQL Server backend this same test suite can target via ODBC_CONNECTION_STRING; SqlMaxDynamicWideString's wchar_t-based storage self-declares NVarchar, giving real Unicode columns on every backend. Verified standalone against the ladder-foundation base: configures and builds with -DMORPH_LADDER_RUNGS=pastebin and no other rung present. Full suite passes: 834 assertions in 51 test cases (SQLite default). Spec-citation and test-type-name lints clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * tests: close two real EditPaste coverage gaps, document a third Investigating PR #89's codecov/patch gap (85.04% vs 97.24% target) found two genuinely reachable, previously-uncovered branches in EditPaste's post-CAS-miss classification (paste_model.cpp's "Zero rows matched: classify why" block) -- distinct from the pre-CAS checks the existing "refuses an immutable paste, an unknown id" test already covers, since those never reach the guarded UPDATE at all: - A concurrent delete between EditPaste's first read and its guarded write throws NotFound, not Conflict -- the row genuinely vanished underneath the pending edit. - A concurrent is_editable flip (simulated directly through the locking connection, since no ordinary action un-edits a paste) throws ValidationError instead. Both forced deterministically via the same WaitForGuardedUpdate SqlLogger hook idiom the existing Conflict test already established -- no sleep_for, no guessing at scheduling. Also investigated (but left undone, with a comment explaining why) a third gap: app.cpp's sweep .onError() path, which decrements sweepInFlight when a swept ExpirePaste fails. Forcing a real SQLITE_BUSY inside sweepExpiredOnce()'s worker-thread-dispatched execute() needs drainPoolIdleMappers()'s "next Acquire() is fresh" guarantee to hold across that async dispatch; confirmed by direct instrumentation that it currently does not (some other Acquire()/Return() pair repopulates the idle pool first), and there is no way to observe which path a given Acquire() took to root-cause that further. Filed LASTRADA-Software/Lightweight#548 requesting SqlLogger::OnConnectionIdle/ OnConnectionReuse (already declared, never called anywhere in the library) actually get wired up, which would answer this directly. Two other apparent gaps (paste_qml_bridges.hpp, app.hpp -- Q_OBJECT/ constructor/destructor declaration lines) are the same llvm-cov per-declaration-line reporting artifact confirmed twice already this session: both classes are thoroughly exercised via their .cpp definitions (0 uncovered lines each), and their constructors are directly instantiated in this file's own App-level tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Yaraslau Tamashevich <y.tamashevich@lastrada.net> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslaut
pushed a commit
that referenced
this pull request
Aug 16, 2026
Split out of application-ladder (originally bundled with pastebin/polls and the shared foundation in #41) into its own PR against the rung-0 foundation (#88). Includes the pool-migration and cursor-race fixes folded in during review of the combined branch: - BookmarkModel/SharedFeedModel/TagModel acquire connections from Lightweight::GlobalDataMapperPool() per execute() call rather than holding one for its own lifetime (WithMapper removed). - GetChangesSince's millisecond cursor boundary race fixed (originally landed on master as its own commit; carried forward here since bookmarks is where the fix lives). Verified standalone against the ladder-foundation base: configures and builds with -DMORPH_LADDER_RUNGS=bookmarks and no other rung present. Full suite passes: 826 assertions in 121 test cases (SQLite default). Spec-citation and test-type-name lints clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslaut
pushed a commit
that referenced
this pull request
Aug 16, 2026
Split out of application-ladder (originally bundled with pastebin/ bookmarks and the shared foundation in #41) into its own PR against the rung-0 foundation (#88). Includes the pool-migration fix folded in during review of the combined branch: - PollModel (this ladder's BRIDGE_MODEL_KEY-shared/keyed model) acquires connections from Lightweight::GlobalDataMapperPool() per execute() call rather than holding one for its own lifetime (WithMapper removed); applyVotes() (private helper, called from within execute() bodies that don't need to share its connection) makes its own independent acquisition. Verified standalone against the ladder-foundation base: configures and builds with -DMORPH_LADDER_RUNGS=polls and no other rung present. Full suite passes: 557 assertions in 68 test cases (SQLite default). Spec-citation and test-type-name lints clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslaut
added a commit
that referenced
this pull request
Aug 16, 2026
* ladder: rung 2 -- bookmarks Split out of application-ladder (originally bundled with pastebin/polls and the shared foundation in #41) into its own PR against the rung-0 foundation (#88). Includes the pool-migration and cursor-race fixes folded in during review of the combined branch: - BookmarkModel/SharedFeedModel/TagModel acquire connections from Lightweight::GlobalDataMapperPool() per execute() call rather than holding one for its own lifetime (WithMapper removed). - GetChangesSince's millisecond cursor boundary race fixed (originally landed on master as its own commit; carried forward here since bookmarks is where the fix lives). Verified standalone against the ladder-foundation base: configures and builds with -DMORPH_LADDER_RUNGS=bookmarks and no other rung present. Full suite passes: 826 assertions in 121 test cases (SQLite default). Spec-citation and test-type-name lints clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * ci: nudge to trigger pull_request workflow run * bookmarks: retype entity string fields to Lightweight strong string types Per docs/superpowers/specs/2026-08-11-strong-storage-types-design.md item 3 (string retyping; item 1 mapper pooling, item 2 pastebin id, and item 4 timestamp retyping are separate, out of scope here). Every plain Light::Field<std::string, ...> in examples/bookmarks/include/bookmarks/db/*.hpp moves to a Lightweight strong string type: - BookmarkRecord::ownerPrincipal, ImportedOpRecord::ownerPrincipal, TagRecord::ownerPrincipal -> SqlAnsiString<64>, matching bookmarks_authorizer.hpp's kMaxPrincipalBytes. - BookmarkRecord::url, faviconPath -> SqlAnsiString<2048> (kMaxUrlBytes). - BookmarkRecord::title -> SqlAnsiString<512> (kMaxTitleBytes). - BookmarkRecord::description, notes -> SqlMaxDynamicAnsiString (unbounded, matching the DTO's own unbounded std::string fields). - ImportedOpRecord::opId -> SqlAnsiString<128> (small idempotency token). - BookmarkOutboxRecord::modelType/entityKey/actionType/principal -> SqlAnsiString<64> each; idempotencyKey -> SqlAnsiString<128> (program-controlled identifiers, no existing named constant). - BookmarkOutboxRecord::payload/result -> SqlMaxDynamicAnsiString (serialized JSON, unbounded). - TagRecord::name -> SqlAnsiString<128> (kMaxTagNameBytes, already existed in tag_dto.hpp). Every bounded field ties back to its DTO-level constant via a static_assert(decltype(Entity::field)::ValueType{}.capacity() == kMaxFooBytes, ...) in the owning model .cpp (bookmark_model.cpp, tag_model.cpp), following the precedent in examples/pastebin/src/models/paste_model.cpp's kMaxSyntaxBytes assertion. Fields with no existing named constant (opId, outbox columns) use the literal N directly with no new constant invented, per the plan's instruction. Updates every model-layer read that converts an entity field back to the plain std::string DTO shape (SqlAnsiString/SqlMaxDynamicAnsiString's std::string conversion operator is explicit, so each such read needs an explicit std::string{...} or .str()/.ToStringView() call): bookmark_model.cpp's toView()/readTagNames()/ListBookmarks/GetChangesSince/ ExportBookmarks, tag_model.cpp's ListTags, shared_feed_model.cpp's ListSharedFeed, and app.cpp's relayOutboxOnce() (BookmarkOutboxRecord -> journal::LogEntry). Writes (DTO std::string -> entity strong string) need no changes: every strong string type's constructor from std::string/ std::string_view is non-explicit, so plain assignment already compiles. Lightweight's Where(...) query-builder value argument is not constrained to the column's declared type either (it binds through SqlVariant, which accepts std::string natively), so no .Where(...) call site needed a change. DTOs, glaze meta specializations, QML files, presenters/bridges, and every test file are unaffected: test_bookmarks_schema.cpp's direct db::*Record field assignments (string literals) and test_bookmark_model.cpp/test_tag_model.cpp/test_app.cpp's read-only BookmarkOutboxRecord .Value() == "literal" comparisons already compile unchanged against the new types. Verified via a from-scratch build of ladder_bookmarks_tests (MSVC/Ninja, build/bookmarks) and the full suite run directly (121 test cases, 826 assertions, all passing against the SQLite test-fixture default). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Address code review feedback on PR #90 - schema.cpp: match DDL column types to their entities' declared Lightweight strong-string capacities -- Varchar(N) for bounded url/title/favicon_path/ tag name, NVarchar(0) for unbounded description/notes/outbox payload+result (was Text() uniformly, which encoded no bound and mirrors none of pastebin's precedent) (per @Copilot) - shared_feed_model.cpp: batch ListSharedFeed's per-bookmark tag-name lookup into 2 queries for the whole page (WhereIn + in-memory grouping) instead of a junction query plus one tag query per junction row (N+1+M) (per @Copilot) - tag_model.cpp: batch ListTags' per-tag bookmark count into 1 query for all of the owner's tags, counted in-memory, instead of one full-row junction query per tag just to read its .size() (per @Copilot) - tag_dto.hpp: fix kMaxTagNameBytes's comment, which claimed TagRecord::name carries no SqlAnsiString capacity to check against -- it is SqlAnsiString<128>, and tag_model.cpp's own static_assert already pins it to this constant (per @Copilot) - bookmark_qml_bridges.cpp: redact the bearer token from a successful Login's replyReceived payload -- the token has already done its one job (installed onto the session) by the time the signal fires, and broadcasting it further invites a future QML handler to display or log a live credential; principal is preserved, and the signal's shape is unchanged (per @Copilot) - test_bookmark_qml_bridges.cpp: update the Login-decode regression test for the redaction above (asserts payload's token is absent, then reuses the sibling test's follow-up-refresh technique to confirm the *real* token was still installed onto the session) Verified: ladder_bookmarks_tests full suite passes against SQLite (260 cases; the 2 unrelated pre-existing "failures" ctest reports on RecordMetadata/ QtWebSocketBackend are a console em-dash encoding artifact in ctest's own -R re-invocation, not real failures -- both pass when run without a name filter). --------- Co-authored-by: Yaraslau Tamashevich <y.tamashevich@lastrada.net> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslaut
added a commit
that referenced
this pull request
Aug 16, 2026
* ladder: rung 3 -- polls Split out of application-ladder (originally bundled with pastebin/ bookmarks and the shared foundation in #41) into its own PR against the rung-0 foundation (#88). Includes the pool-migration fix folded in during review of the combined branch: - PollModel (this ladder's BRIDGE_MODEL_KEY-shared/keyed model) acquires connections from Lightweight::GlobalDataMapperPool() per execute() call rather than holding one for its own lifetime (WithMapper removed); applyVotes() (private helper, called from within execute() bodies that don't need to share its connection) makes its own independent acquisition. Verified standalone against the ladder-foundation base: configures and builds with -DMORPH_LADDER_RUNGS=polls and no other rung present. Full suite passes: 557 assertions in 68 test cases (SQLite default). Spec-citation and test-type-name lints clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * ci: nudge to trigger pull_request workflow run * polls: retype plain std::string entity fields to Lightweight strong string types Per docs/superpowers/specs/2026-08-11-strong-storage-types-design.md item 3's polls inventory (spec file since removed from the tree by 2851376, recovered from git history for this change). poll_entity.hpp: - PollRecord::title -> Light::SqlAnsiString<kMaxTitleBytes> (200) - OptionRecord::label -> Light::SqlAnsiString<kMaxOptionLabelBytes> (100) - VoteRecord/CommentRecord/VoteHistoryRecord::participantName -> Light::SqlAnsiString<kMaxParticipantNameBytes> (80) - CommentRecord::body -> Light::SqlAnsiString<kMaxCommentBytes> (500) - VoteHistoryRecord::previousVotesJson -> Light::SqlMaxDynamicAnsiString (serialized JSON, unbounded) - PollEventRecord::kind -> Light::SqlAnsiString<32> (short internal tag, no existing DTO constant) - PollEventRecord::summary -> Light::SqlMaxDynamicAnsiString (free text, no existing bound) pollId/adminToken/participantToken are untouched -- already SqlAnsiString<kTokenBytes> from an earlier pass. poll_model.cpp: added a templated textOf(SqlAnsiString<N>) plus a SqlMaxDynamicAnsiString overload (mirroring pastebin::PasteModel's textOf() precedent) and wrapped every entity->DTO read of these fields in it; DTO->entity writes needed no changes since Light::Field's assignment operator already accepts anything constructible into the field's value type. Added five static_asserts pinning each bounded field's SqlAnsiString capacity to its DTO-level kMax*Bytes constant (matching pastebin's kMaxSyntaxBytes static_assert), so title/label/ participantName/body's storage capacity and validate() bound can never drift apart silently. kind/summary have no DTO constant to pin against and are left unasserted, per the spec's own note on those two fields. schema.cpp: poll_events.kind's DDL widens from Varchar(16) to Varchar(32) and summary from Varchar(200) to Text(), to match the entity's new capacities (kind's entity comment already documented 32 as the chosen size; summary is now genuinely unbounded like previous_votes_json's existing Text() column). test_polls_schema.cpp and every other polls test file needed no changes: all *Record field assignments in these tests use string literals, which construct implicitly into SqlAnsiString<N>/SqlMaxDynamicAnsiString the same way they did into plain std::string, and its two Field::Value() comparisons against string literals resolve through SqlFixedString's operator==(string_view) overload. Verified via the existing build/polls tree (MORPH_LADDER_RUNGS=polls, MSVC/Ninja, Debug), reconfigured and rebuilt incrementally: full rebuild succeeded and all 68 ladder-polls-labeled ctest cases pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * polls: fix cpp-review + Copilot findings on PR #91 cpp-review finding: - schema.cpp: previous_votes_json/summary use NVarchar(0), not Text() -- both back a SqlMaxDynamicAnsiString entity field (genuinely unbounded), and Text() encodes no such contract; matches pastebin's own NVarchar(0) precedent for unbounded storage and the fix already applied to bookmarks' equivalent Text()-vs-entity-capacity mismatch (PR #90) Copilot findings: - VoteView.qml: each vote RadioButton's onToggled fired unconditionally, including on the checked->unchecked transition Qt's shared ButtonGroup triggers on the sibling that just lost the selection -- so clicking one choice could have its own setPick() call overwritten right back by the previously-checked button's own unconditional handler, depending on which of the two `toggled` signals QML fires second. Guarded all three (Yes/If need be/No) on `if (checked)`. - types.hpp: OptionId/PollEventId::operator*()'s doc comment claimed "UB when empty, exactly like std::optional::operator*" -- both wrap a plain std::int64_t with 0 as their own sentinel, not a std::optional, so reading either when "empty" just returns 0, never UB. Rewrote both comments to state the real contract (Copilot's suppressed-comments list named PollEventId only; OptionId carries the identical copy-pasted comment one type up in the same file, so fixed both). Verified: full ladder_polls_tests suite (68 cases) passes against SQLite; the 1 unrelated ctest-reported failure (QtWebSocketBackend concurrent dispatch, untouched by this diff) is the same console em-dash encoding artifact in ctest's -R/-I re-invocation seen on PR #90, not a real failure. --------- Co-authored-by: Yaraslau Tamashevich <y.tamashevich@lastrada.net> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The first slice of the application ladder (previously all bundled in #41, 236 files / 32.8k insertions): the shared foundation every rung builds on, with no rung's application code included. Pastebin, bookmarks, and polls land as their own follow-up PRs against this one.
Per
LADDER.md's own framing, rung 0 has no app of its own — this is rung 0.Contents
.github/workflows/ci.yml,wasm-ladder.yml,cmake/morph_add_rung.cmake,cmake/compiler_options.cmake,codecov.yml, and the top-levelCMakeLists.txt/vcpkg.jsonchanges needed to build an opt-in ladder (-DMORPH_BUILD_LADDER=ON) alongside existing example/test targets, without disturbing them.examples/LADDER.md,IMPLEMENTATION.md,TESTING.md,FINDINGS.md— the two binding companion documents every rung is held to, plus the finding pipeline's scoreboard/triage process.examples/{crm,forge,kanban,ledger,lims}/README.md— rungs 4–8, each a finished requirements study. Docs only, no code; building any of them is a separate decision perLADDER.md's program-scope note.examples/common: the GUI presenter base (presenter.hpp),AppContext(LocalBackend/QtWebSocketBackend/WASM mode switch), the injectable clock, and the full testkit (BackendRig, theDbFixturefamily, fault-injection proxy, strand interleaver, event poller) every rung's tests depend on — with its own unit tests (ladder_common_tests).registry.hpp/remote.hppchanges the rungs needed, exercised bytests/test_quantity_forms.cppand the newtests/test_remote_execute_ordering.cpp.Verification
Configured and built standalone (
-DMORPH_BUILD_LADDER=ON -DMORPH_BUILD_QT=ON) with no rung directories present —examples/CMakeLists.txt's rung-selection loop already tolerates this ("no rung exists yet at rung 0" is a real code path, not a placeholder).morph_tests: 9773 assertions passmorph_qt_tests: 496 assertions passladder_common_tests: 212 assertions pass (against its SQLite default)check_spec_citations.sh,check_test_type_names.sh,check_deprecated_markers.sh: all cleanSequencing
This targets
master. The per-rung PRs (pastebin, bookmarks, polls) will target this branch (or be rebased onto master once this merges) and are marked draft where they depend on it.