Skip to content

ladder: shared infrastructure, docs, and framework prerequisites (rung 0) - #88

Merged
Yaraslaut merged 7 commits into
masterfrom
ladder-foundation
Aug 15, 2026
Merged

ladder: shared infrastructure, docs, and framework prerequisites (rung 0)#88
Yaraslaut merged 7 commits into
masterfrom
ladder-foundation

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

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

  • CI/CMake: .github/workflows/ci.yml, wasm-ladder.yml, cmake/morph_add_rung.cmake, cmake/compiler_options.cmake, codecov.yml, and the top-level CMakeLists.txt/vcpkg.json changes needed to build an opt-in ladder (-DMORPH_BUILD_LADDER=ON) alongside existing example/test targets, without disturbing them.
  • Cross-rung docs: 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.
  • Design-annex README stubs: 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 per LADDER.md's program-scope note.
  • Shared examples/common: the GUI presenter base (presenter.hpp), AppContext (LocalBackend/QtWebSocketBackend/WASM mode switch), the injectable clock, and the full testkit (BackendRig, the DbFixture family, fault-injection proxy, strand interleaver, event poller) every rung's tests depend on — with its own unit tests (ladder_common_tests).
  • Framework prerequisites: registry.hpp/remote.hpp changes the rungs needed, exercised by tests/test_quantity_forms.cpp and the new tests/test_remote_execute_ordering.cpp.
  • The lint-gate scripts/tests already merged via ci+scripts: lint gate for file-scope test type name collisions #85, carried forward.

Verification

Configured and built standalone (-DMORPH_BUILD_LADDER=ON -DMORPH_BUILD_QT=ON) with no rung directories presentexamples/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 pass
  • morph_qt_tests: 496 assertions pass
  • ladder_common_tests: 212 assertions pass (against its SQLite default)
  • check_spec_citations.sh, check_test_type_names.sh, check_deprecated_markers.sh: all clean

Sequencing

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.

…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>
Yaraslau Tamashevich and others added 6 commits August 14, 2026 23:09
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
Yaraslaut merged commit 557b892 into master Aug 15, 2026
25 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant