Skip to content

ladder: rung 4 -- kanban - #121

Open
Yaraslaut wants to merge 33 commits into
masterfrom
ladder-kanban-impl
Open

ladder: rung 4 -- kanban#121
Yaraslaut wants to merge 33 commits into
masterfrom
ladder-kanban-impl

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Summary

Rung 4 of the application ladder: a multi-project kanban board backend — projects, columns, swimlanes, tasks, drag-and-drop moves with WIP limits, comments, per-project RBAC, a journal-derived activity stream, and an offline stack. This is the ladder's designated showcase — the first app where concurrency, authorization, offline, and the journal are all load-bearing at once.

Design spec: docs/superpowers/specs/2026-08-16-kanban-rung4-design.md
Implementation plan: docs/superpowers/plans/2026-08-16-kanban-backend.md

Implemented via Subagent-Driven Development: 20 sequential tasks, each with a fresh implementer + independent task review (+ fix round where needed), followed by one final whole-branch review and one fix round for its findings. Full process ledger: .superpowers/sdd/2026-08-16-kanban-backend/progress.md (kept in this PR for reviewer reference — see note below).

What's implemented

  • ProjectAdminModel/AuthModel — project lifecycle, RBAC role management, token-based login.
  • BoardModel (the core, shared-instance model, keyed by project id) — OpenBoard/GetBoardState, column/swimlane/task CRUD, AddComment, MoveTaskPosition (WIP limits, dense position renumbering across source and destination), GetEventsSince (polling), GetActivity (journal-derived).
  • Per-project RBAC enforced inside BoardModel::execute() via requireRole(Role), mirroring polls::PollModel::requireAdmin()'s precedent — not a change to IAuthorizer.
  • Exactly-once semantics: client-supplied opId + a server-side applied-ops ledger, checked after the role gate and before any re-validation.
  • Offline stack integration tests: dropped-reply-frame exactly-once, reconnect-and-replay convergence, 32-board SQLite-contention (no timeout-then-committed double-apply).
  • Concurrent-move stress test (N=4 real threads, ThreadPoolExecutor, Local rig mode — sanitizer-friendly per TESTING.md's convention of keeping Qt stacks out of the sanitizer matrix).
  • Testkit additions absorbed for this rung: action_driver.hpp (SeededScript), offline_rig.hpp (OfflineRig), client_pool.hpp/convergence.hpp (ClientPool, pollUntilConverged).

Framework fixes found and made along the way

  • IModelHolder::attachActionLog now forwards to a model-level attachActionLog via a new ModelLevelActionLogAttachable<M> concept — closes a gap where a registry-constructed shared model never received its own journal attachment (include/morph/core/model.hpp).
  • QtWebSocketBackend/SocketBackend::listInstances now stamp env.session like every sibling call — a pre-existing gap that broke SigningAuthorizer-gated instances() over sockets (one line per file).
  • A pre-existing detail::throwIfListenFailed name collision between examples/common/testkit/fault_proxy.hpp and backend_rig.hpp (same namespace, different bodies) — renamed the fault_proxy.hpp copy; would only have surfaced once a single TU included both headers.

Upstream (not fixed in this branch, filed externally): a silent-data-loss trap in Light::BelongsTo assignment (raw-integer assignment to an Update()-bound field silently loses modification tracking) — LASTRADA-Software/Lightweight#551. This branch's own code avoids it by routing through the correct overload (board_model.cpp's MoveTaskPosition), documented inline.

Security fixes from the final whole-branch review

The final review (dispatched after all 20 tasks, on the most capable available model per the SDD process) found two Critical, cross-cutting bugs invisible to any single task's diff:

  • Unauthorized reads: OpenBoard/GetBoardState/GetEventsSince/GetActivity had no role check at all — any authenticated principal could read any project's board, comments, and activity journal. Fixed by gating all four with requireRole(Role::Viewer) (or an equivalent resolved-project-id check for OpenBoard).
  • Cross-tenant writes: CreateTask/AddComment/MoveTaskPosition never re-verified their target column/task belonged to the attached project. Fixed with new requireSwimlaneBelongsToProject/requireTaskBelongsToProject helpers, called unconditionally before every write.

Both fixes are covered by new negative tests and were independently re-verified by a second review pass, including a live mutation test (temporarily disabling one check, confirming the corresponding test then fails, restoring it) to prove the new tests aren't vacuous.

Also fixed: MoveTaskPosition's exactly-once ledger-hit replay was re-journaling an operation it didn't perform, compensated for by a lossy read-side dedup in GetActivity — removed both; the design spec's now-disproven premise (that the framework's own auto-append double-journals) was corrected after empirically capturing a live FileActionLog and confirming it doesn't.

Explicitly deferred (not silently dropped — see the ledger for full reasoning)

  • ThreadSanitizer CI coverage for the concurrent-move stress test (tagged [tsan], but no CI job currently runs the ladder under a sanitizer).
  • The offline reconnect test drives the ledger directly rather than through the real SqliteOfflineQueue/SyncWorker/ReconnectCoordinator/NetworkMonitor stack.
  • Stress test's seed-combination and RNG-determinism bugs (MORPH_STRESS_SEED collapses per-client seed offsets; INFO() in a constructor doesn't survive to failure output).
  • process_pool.hpp (a design-spec §6 item) and the client_pool.hpp/convergence.hpp interleaved-replay convergence test it was meant to support.
  • Automation rules and task attachments (both out of scope for this rung per examples/kanban/README.md's "Deferred within this rung" section, decided during design).

Filed issues

  • morph#112 — IOfflineQueue has no depth bound or overflow policy [framework gap]
  • morph#113 — QtWebSocketBackend/SocketBackend::listInstances session-stamping (fixed in this branch)
  • morph#114 — IModelHolder journal-attachment forwarding gap (fixed in this branch)
  • LASTRADA-Software/Lightweight#551BelongsTo silent-data-loss on raw-integer assignment (upstream, not fixed here)

Test results

ladder_kanban_tests: 270 assertions / 57 test cases, all green.
ladder_common_tests: 295 assertions / 84 test cases, all green (no regression from the testkit rename).

Process note

This PR includes .superpowers/sdd/2026-08-16-kanban-backend/progress.md, the full SDD execution ledger — kept for reviewer reference since it documents the reasoning behind every non-obvious decision (RBAC identity, BRIDGE_MODEL_KEY-on-strong-id workaround, the two Critical findings and their fixes, all deferred items with rulings). Happy to squash/drop it before merge if preferred.

Yaraslaut pushed a commit that referenced this pull request Aug 17, 2026
…equired default

clang's -Wswitch-default (enabled under -Weverything -Werror on the
Linux clang-coverage / all-optional-features / Application-ladder CI
legs) requires an explicit default: label even on a switch that
already covers every enumerator -- confirmed this is the only failure
across all four failing jobs on PR #121's one CI run to date, and that
this exact tension (exhaustive switch needing a default anyway) is an
already-accepted pattern elsewhere in the ladder:
examples/pastebin/include/pastebin/units.hpp's UnitTraits<Unit>::meta
has the identical shape. CI's flag list already carries
-Wno-covered-switch-default, so adding the default arm satisfies
-Wswitch-default without tripping the opposite warning -- verified by
compiling a standalone repro of the exact switch shape against clang
22 (the CI compiler version) with the full CI flag list, both before
(fails on -Wswitch-default) and after (clean) this change.

No functional change -- the added default arm returns the same
fallback roleToString() already returned unconditionally before this
fix (Role::Viewer's string), for a code path every enumerator already
short-circuits before reaching.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.35294% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
examples/common/testkit/action_driver.hpp 77.41% 4 Missing and 3 partials ⚠️
examples/common/testkit/convergence.hpp 77.77% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Yaraslau Tamashevich and others added 28 commits August 17, 2026 23:36
Filed morph#112 (IOfflineQueue has no depth bound or overflow policy) --
verified against offline_queue.hpp/sqlite_offline_queue.hpp/
file_offline_queue.hpp: enqueue() has no capacity parameter, no depth
cap, and no overflow signal anywhere in the interface or either shipped
implementation. Needs a framework-level decision (evict-oldest vs.
reject-newest vs. app-defined policy) before rung 4's offline stack
(step 7) can define its own overflow behavior.
…orizer

Dispatched an analysis agent on whether per-project RBAC
(viewer/member/manager) belongs in IAuthorizer::authorizeInstance or
inside BoardModel::execute() itself. Verified recommendation: in-model,
mirroring polls::PollModel::requireAdmin()'s exact precedent.

docs/spec/core/shared_instances.md already settles this for shared
instances generally (BoardModel is one): teaching authorizeInstance
about a per-instance owner *set* was explicitly rejected there as
adding complexity to a hook the model layer already handles better.
docs/spec/security.md's own local-path note clinches it independent of
that: authorizeInstance never runs for LocalBackend callers at all, so
an IAuthorizer-only RBAC check would silently not exist locally --
BoardModel needs its own check regardless of what the authorizer does,
making a framework interface change pure duplicated surface with no
coverage gain.

Tightened step 4's wording so a future reader doesn't reopen this
question.
Resolves examples/kanban/README.md's design questions in writing, per
the ladder's own discipline rule. Covers steps 1-5+7 (steps 6/8 stay
deferred, per the README's own scoping):

- Exactly-once (MoveTaskPosition): generalizes bookmarks::ImportBookmarks'
  client-op-id + server-side applied-ops-ledger pattern, storing the
  full serialized GetBoardResult (not a placeholder) so a replaying
  client reconciles against the real outcome.
- Strand ordering / WIP limits / position renumbering: rely entirely on
  the framework's existing strand-per-instance guarantee (verified
  against docs/spec/core/shared_instances.md); no new locking.
- Per-project RBAC: in-model requireRole() check mirroring
  PollModel::requireAdmin(), not an IAuthorizer interface change --
  resolved via an analysis agent, grounded in shared_instances.md's and
  security.md's own already-written positions.
- Activity stream: derived from IActionLog::entries(entityKey) -- no
  new storage.
- Offline: composes SqliteOfflineQueue/SyncWorker/ReconnectCoordinator
  as-is; verified (not assumed) that a reconnect flap cannot preempt an
  in-progress replay.

Two testkit-scope findings, verified by file-existence checks:
- action_driver.hpp/process_pool.hpp/offline_rig.hpp are rung 4's own
  obligation per examples/TESTING.md's ownership table (confirmed none
  exist yet).
- client_pool.hpp/convergence.hpp were TESTING.md's documented rung-3
  obligation but polls (merged, PR #91) never built them -- absorbed
  into this rung's scope since kanban's own convergence DoD item needs
  them regardless of original ownership.

Framework gap filed and cross-referenced: morph#112 (IOfflineQueue has
no depth bound or overflow policy), verified against
offline_queue.hpp/sqlite_offline_queue.hpp/file_offline_queue.hpp.
…l 6 gaps

An independent Fable 5 review (dispatched per user request) verified every
citation in the design spec against the actual code/docs and caught one
load-bearing defect plus several real gaps:

Defect (verified, corrected):
- Section 3 originally cited PollsAuthorizer (AllowAllAuthorizer-derived) as
  KanbanAuthorizer's shape. security.md's own documented behavior: an
  authorizer that never authenticates has dispatchExecute clear
  Context::principal to empty before every remote dispatch -- so
  requireRole()'s project_has_roles lookup would have nothing to key on over
  the socket, silently diverging from Local-mode tests where a principal can
  be hand-populated. Corrected to BookmarksAuthorizer's shape
  (SigningAuthorizer-derived, a real verifying authorizer) and added an
  Identity subsection covering the login/token dependency this pulls in and
  who seeds a project's first manager role.

Gaps closed:
- Section 4's "attachActionLog() convention" didn't exist anywhere in the
  ladder (verified: no rung calls it) -- kanban is the first to use it, not
  a follower; stated as such, with the LocalBackend-has-no-LogProvider and
  same-log-instance plumbing this now requires spelled out.
- Ledger hits (section 1) would double-journal since the auto-append
  registrar has no visibility into an action's own opId; resolved by
  collapsing consecutive identical-payload LogEntry rows on the activity
  view's read side rather than touching the framework's append path.
- GetEventsSince's own design was undecided; resolved as a real
  board_events table (polls::PollEventRecord's exact precedent), distinct
  from the activity stream's journal-derivation -- LogEntry::seq is
  documented as process-local, unusable as a durable poll cursor.
- ProjectAdminModel's write surface (a separate strand from BoardModel) is
  now drawn explicitly, with the column-deleted-mid-drag race resolved via
  re-validation inside MoveTaskPosition's own transaction, not cross-strand
  coordination.
- Section 5's DoD gaps filled: enqueue-on-failed-dispatch trigger,
  DeadLetterSink wiring, conflict-on-replay behavior, observability
  assertions.
- requireRole-vs-ledger-hit ordering (section 1) made explicit: role check
  runs before the ledger lookup, so a demoted caller's replay is denied
  rather than handed a stored result their current role could not produce.
- Minor: fixed a wrong citation attribution, added the strand interleaver
  to the test plan, noted board_applied_ops' own unbounded retention.

Also updated examples/kanban/README.md's step 4 wording to match the
corrected authorizer shape.
Implements docs/superpowers/specs/2026-08-16-kanban-rung4-design.md's
steps 1-5+7 scope: schema/entities, BoardModel (CRUD, MoveTaskPosition
with WIP limits/position renumbering/exactly-once ledger, RBAC gate,
activity stream, GetEventsSince), ProjectAdminModel (project lifecycle,
role management), KanbanAuthorizer (SigningAuthorizer-derived per the
spec's corrected identity decision), plus the five testkit files rung 4
owns (action_driver.hpp, offline_rig.hpp, client_pool.hpp,
convergence.hpp -- the last two absorbed from rung 3's undelivered
obligation per spec section 6) and the DoD stress/offline test suites.

Backend + testkit only, fully testable via BackendRig with no GUI
dependency -- GUI (presenters/QML bridges/QML views) is a separate
follow-on plan, split out since this plan already runs to 20 tasks and
GUI work only starts once the model surface it binds against exists.

Self-review found and closed one real gap: the original draft had no
task for design spec section 5's offline DoD tests (exactly-once under
FaultProxy::dropReply(), kill-the-network via offline_rig.hpp, SQLite
contention via DbBusyFixture) -- added as Task 20.

Two tasks (19's stress-test body, 20's three offline test bodies) are
deliberately left as structured comments over real TEST_CASE names
rather than guessed implementations, since they depend on
StrandInterleaver's/FaultProxy's/DbBusyFixture's own exact APIs that
should be read fresh at execution time rather than reproduced from
memory here -- flagged inline as intentional, not silent placeholders.
- CMakeLists.txt with morph_add_rung(NAME kanban) and minimal boilerplate
- Skeleton headers: database.hpp, db_model.hpp, app.hpp, kanban_authorizer.hpp
- Minimal implementations: kanban_authorizer.cpp, schema.cpp, server/main.cpp
- All tokens replace polls equivalents (polls→kanban, Polls→Kanban, POLLS→KANBAN)
- Build verification: ladder_kanban_lib target builds successfully
CRITICAL FIX:
- KanbanAuthorizer now derives from SigningAuthorizer (was AllowAllAuthorizer)
  - Matches BookmarksAuthorizer pattern per design spec §3 (corrected identity)
  - Provides trustworthy Context::principal for BoardModel::requireRole()
  - Implements setTokenIssuer()/tokenIssuer() process-global installation

HEADER/SOURCE UPDATES:
- app.hpp: Updated docs to reflect SigningAuthorizer + TokenIssuer requirement
- src/server/main.cpp:
  - Added KANBAN_TOKEN_SECRET env var (required, no default per security.md)
  - Installs TokenIssuer before App construction
  - Fixed 'kanban' apostrophe typo in file comment

TESTS:
- Created examples/kanban/tests/ with placeholder test_placeholder.cpp
- ladder_kanban_tests target now builds successfully

MINOR FIXES:
- schema.cpp: Replaced dangling using statement with Task 3+ note
- db_model.hpp: Fixed access specifier indentation (column 2, per project convention)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ban_authorizer.cpp

KanbanAuthorizer, its header, and CMakeLists.txt wiring already existed
from Task 1's fix round. This closes the one Minor finding parked from
that review: setTokenIssuer/tokenIssuer used an unguarded function-local
static shared_ptr slot, unlike bookmarks::auth's std::mutex-guarded
equivalent. Replaces it with the identical detail::tokenIssuerMutex()/
tokenIssuerSlot() pattern from bookmarks_authorizer.hpp:265-308.

Adds the missing test_kanban_authorizer.cpp (Task 7's Step 1/6), plus a
third case covering the mutex-guarded slot itself, mirroring bookmarks'
own "share one process-global slot" coverage.
… management

- ProjectAdminModel::execute(CreateProject) creates the project and seeds
  the caller as its first Manager role, in one transaction.
- ::execute(SetMemberRole)/::execute(RemoveMember) are Manager-gated via
  requireRole(); SetMemberRole deletes-then-recreates the role row.
- ::execute(GetProjectRoles) is Viewer-gated (any member may list).
- requireRole() loads the project first (NotFound if absent), then the
  caller's own role row (Forbidden if absent or below the minimum) --
  mirrors PollModel::requireAdmin()'s ordering.
- AuthModel::execute(Login) mirrors bookmarks::AuthModel exactly, using
  kanban::auth::tokenIssuer(); added isValidPrincipal/isReservedPrincipal
  to kanban::auth (mirroring bookmarks::auth) since Login/AuthModel need
  them and kanban had none yet.
- New examples/kanban/include/kanban/dto/auth_dto.hpp, ported from
  bookmarks' auth_dto.hpp with the namespace renamed.
- CMakeLists.txt: added src/dto/auth_dto.cpp to ladder_kanban_lib's
  explicit target_sources() (the rung's default glob doesn't cover
  src/dto/), mirroring bookmarks' identical treatment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…UD/AddComment

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-once ledger

Task 10 of the kanban rung-4 plan (design spec §1/§2). Implements
BoardModel::execute(const MoveTaskPosition&): ledger lookup by
(projectId, opId) before any re-validation (hit -> decode and return
the stored GetBoardResult verbatim; miss -> requireColumnBelongsToProject
cross-strand re-check -> WIP-limit check -> delete-then-recreate
position renumbering -> ledger write, all inside one SqlTransaction).

Fixes one defect in the task brief's literal code: the brief assigned
the raw FK integer directly to the BelongsTo fields
(task.column = static_cast<uint64_t>(*action.columnId)). That compiles
(BelongsTo's non-explicit value constructor + copy-assignment accept
it) but never marks the field _modified, so the following
mapper->Update(task) would silently omit column_id/swimlane_id from
its SET clause -- the move would appear to succeed but never persist.
Verified empirically: reverting to the brief's literal assignment made
the first new test fail exactly this way. Fixed by loading the target
ColumnRecord/SwimlaneRecord rows (already needed for the WIP-limit
check) and assigning those objects instead, mirroring this file's own
rec.project = project; pattern for every other BelongsTo field.

Also added a swimlane-belongs-to-project re-check alongside the
brief's column re-check, for the same cross-strand reason design spec
§2 gives for the column check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds BoardModel::requireRole(Role minimum), mirroring
ProjectAdminModel::requireRole's shape (design spec §3's explicit
"not shared code" note -- each model gets its own copy since
BoardModel and ProjectAdminModel have separate mapper/entity access).

Gates CreateColumn, CreateSwimlane, CreateTask, AddComment, and
MoveTaskPosition at Role::Member. OpenBoard, GetBoardState, and
GetEventsSince remain ungated -- any attached caller, even a bare
Viewer, may read.

For MoveTaskPosition, the gate call runs unconditionally at the top
of execute(), before the exactly-once ledger lookup: a demoted
caller replaying a known opId must not retrieve a stored result
their current role could no longer produce.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dup on read

BoardModel::attachActionLog/logAction is a model-level mirror of
IModelHolder::attachActionLog/recordIfAttached, not a call into it: a
plain BoardModel a unit test constructs directly has no IModelHolder
wrapping it, so the registry's auto-append (ActionDispatcher's runner,
registry.hpp) never fires for that path. BoardModel keeps its own
shared_ptr<IActionLog> + entity key and appends its own LogEntry at
the end of every mutating execute(), including the MoveTaskPosition
ledger-hit replay path (reproducing the same double-journal the
framework's own auto-append would produce for a holder-wrapped
instance). execute(GetActivity) derives the stream from
IActionLog::entries(entityKey) and collapses consecutive entries with
identical actionType+payload on the read side, per design spec section 4.
…et matrix)

Task 14: ports examples/polls/tests/test_shared_instance_lifecycle.cpp's
three coverage pieces to kanban's BoardModel/KanbanAuthorizer --

1. BoardModel over the full backend-mode matrix (Local/LocalSingleThread/
   Socket): CreateProject (plain, non-keyed ProjectAdminModel handler) ->
   handler.execute(OpenBoard{projectId}) keyed attach -> CreateColumn ->
   GetBoardState.
2. N AllowShared BoardModel handlers on one projectId observe each other's
   writes, and instances() reflects the instance's real lifetime (present
   while attached, gone once every attacher releases it).
3. A poisoned attach to a stale projectId fails identically (NotFound) on
   retry from the same handler -- the no-op-on-same-primary guard means it
   never re-points, and OpenBoard's own loadProjectById() re-runs every
   call, so there is no silently half-hydrated success.
4. Cross-project role isolation: a role granted on one project does not
   leak into a different project the same handler later attaches to.

KanbanAuthorizer is SigningAuthorizer-derived (unlike polls'
AllowAllAuthorizer-derived PollsAuthorizer), so every BackendRig client
needs a real signed session token installed via Bridge::setDefaultSession
before BoardModel::requireRole()'s principal-keyed lookup can work --
mirrors test_bookmark_model.cpp's identical TokenIssuer/setDefaultSession
setup for BookmarksAuthorizer, kanban's other SigningAuthorizer-derived
authorizer.

Also fixes a latent framework bug this task's own multi-handler
instances() test surfaced: QtWebSocketBackend::listInstances (and
morph::net::SocketBackend's identical implementation) built the wire
envelope without stamping env.session, unlike every other envelope-
building call site in both classes (register/attach/assign/execute/
deregister all set it). RemoteServer's instances handler authorizes with
IAuthorizer::authorize(env.session, typeId, {}), so a Socket-mode client
with a SigningAuthorizer got 'unauthorized' calling instances() even
after a successful, correctly-authenticated execute() on the same
connection. Every existing rung's instances() coverage used an
AllowAllAuthorizer-derived authorizer (or ran Local/in-process), so
authorize() was always permissive regardless of session and never
exercised this path -- kanban is the first rung to combine a
SigningAuthorizer with a Socket-mode instances() call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
App::App(actionLogPath, tokenSecret, workers) wires the whole server side:
worker pool, RemoteServer with a real KanbanAuthorizer (SigningAuthorizer-
derived, verified against tokenSecret), the durable FileActionLog installed
process-wide via morph::journal::setActionLog, the process-global
TokenIssuer AuthModel mints tokens from, and RemoteServer::setLogProvider
supplying the same FileActionLog instance for registry-constructed, keyed
BoardModel attaches. Mirrors bookmarks::App's structure (this rung has no
background worker/timer, so App stays plain C++, no QObject).

app.hpp's constructor signature grew a tokenSecret parameter: the header
existed from Task 1's scaffolding with no way to give KanbanAuthorizer/
TokenIssuer a real, verifiable secret at all, which main.cpp's own
speculative stub (also from Task 1, before App existed) did not supply
either -- both needed fixing together for a real deployment to work.
main.cpp now passes KANBAN_TOKEN_SECRET straight through to App instead of
installing the TokenIssuer itself.

Resolves the load-bearing gap flagged in Task 13's review: App wires
RemoteServer::setLogProvider so a registry-constructed, keyed-attach
BoardModel's own attachActionLog is called with the SAME IActionLog
instance the holder's auto-append writes to (via the previous commit's
onActionLogAttached forward) -- not a separate log, not no log.

BoardModel::logAction also gained a flush() call after append(): proven
necessary by this task's own end-to-end test, which found GetActivity
nondeterministically missing an entry it had just recorded through
FileActionLog, because append() writes through buffered C stdio with no
implicit flush and entries() reads through a separate ifstream that cannot
see unflushed bytes. InMemoryActionLog::flush() is a no-op, so this is free
for every non-App test that attaches an in-memory log directly.

test_app.cpp includes the proof this gap is actually closed: dispatches
CreateProject/OpenBoard(AllowShared, keyed)/CreateColumn through App's real
RemoteServer via SimulatedRemoteBackend (the same in-process-but-real-
dispatch path bookmarks::App's own metadata worker uses, not LocalBackend
or a direct BoardModel construction), then confirms GetActivity returns the
CreateColumn entry and that the same durable log file holds it. A companion
case confirms a plain (non-shared) BoardModel registration also works, via
the process-wide default log ModelFactory::create<Model>() attaches
independently of LogProvider/contextKey.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… invariant hook

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sertion (absorbed from rung 3)

BackendRig has no clientCount()/nClients() accessor (brief's Step 4
assumption was wrong, not just misnamed -- verified against
backend_rig.hpp's actual public interface and every existing call site,
none of which reads a count back from the rig). ClientPool's constructor
takes nClients as an explicit parameter instead, matching what every
caller already has on hand from its own BackendRig{mode, nClients, ...}
call.

bridge(i) and executor() are confirmed correct as the brief assumed.

Adds test_client_pool.cpp (not in the brief) to exercise ClientPool
against a real BackendRig across all three modes, since the brief's
Step 1 test only covers convergence.hpp.
…mode)

test_kanban_stress.cpp fires ~50 MoveTaskPosition calls per client from 4
AllowShared BridgeHandler<BoardModel> clients sharing one projectId's
keyed instance, all non-blocking (execute() returns a Completion; nothing
awaits between fires), so BoardModel's shared strand genuinely races
across Mode::Local's real ThreadPoolExecutor{4} worker threads -- the
condition a ThreadSanitizer CI leg over this test exists to check.

The brief's own 'StrandInterleaver' premise did not survive contact with
the real header: strand_interleaver.hpp defines DeterministicExecutor,
tested directly against morph::exec::detail::StrandExecutor in
test_strand_interleaver.cpp, not a class named StrandInterleaver.
BackendRig{Mode::Local, ...} also builds its own ThreadPoolExecutor
internally with no seam to substitute a DeterministicExecutor underneath
LocalBackend's strand, so that harness is not wireable into a
BackendRig-driven test at all. Determinism here instead comes from
SeededScript's seeded RNG (MORPH_STRESS_SEED reproduces a failing run).

Running the new test surfaced a real bug in MoveTaskPosition's position
renumbering (Task 10): it only renumbered the destination
(columnId, swimlaneId) pair, never the source, so a task moving out of a
column left the remaining tasks there with a permanent gap instead of a
dense 0..n-1 run -- violating design spec section 2's per-pair density
invariant. Fixed in board_model.cpp by adding a source-side renumbering
pass, gated on source != destination so a same-pair reorder isn't
double-renumbered. Locked in with both the stress test and a minimal
deterministic single-threaded regression case in test_board_model.cpp.

Full investigation notes: docs/superpowers/sdd/2026-08-16-kanban-backend/task-19-report.md

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ect convergence, SQLite contention

Adds examples/kanban/tests/test_kanban_offline.cpp with the three DoD
scenarios design spec §8 names:

- Dropping MoveTaskPosition's reply frame and retrying via the same opId
  is exactly-once, not double-applied (FaultProxy + BackendRig::Socket).
- Reconnecting after a dropped connection replays the offline queue and
  converges (OfflineRig-equivalent drop/revive sequence against a
  directly-built RemoteServer/QtWebSocketServer stack, since OfflineRig
  cannot attach to a BackendRig-owned server -- see report for why).
- 32 boards writing concurrently under genuine SQLite contention
  (DbBusyFixture) never show a timeout-then-committed double-apply.

Also fixes a pre-existing name collision this task's test file is the
first to surface: fault_proxy.hpp and backend_rig.hpp both defined
morph::ladder::testkit::detail::throwIfListenFailed(bool) with different
bodies in the same namespace -- a hard redefinition error the moment a
single translation unit includes both headers, which no prior test file
ever did. Renamed fault_proxy.hpp's copy to throwIfFaultProxyListenFailed
(and its one call site plus the one TEST_CASE naming it directly);
backend_rig.hpp's copy and every other call site are untouched.

Full API-mismatch/finding writeup in
.superpowers/sdd/2026-08-16-kanban-backend/task-20-report.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… DoD test

The SQLite-contention TEST_CASE asserted an exactly-once invariant on
both a 'threw' and a 'succeeded' branch, but every real run before this
fix produced 0 successes / 32 failures -- the 'succeeded calls also
apply correctly' half was dead code.

Two root causes, both confirmed via temporary instrumentation (added,
used, fully removed):

1. ScopedShortBusyTimeout's PRAGMA busy_timeout hook alone doesn't
   touch the sqliteodbc driver's own outer retry ceiling
   (DbFixture's baked-in connection-string Timeout=5000). Every call
   still failed at ~5.1-5.2s until the hook also shortened the
   *default* connection string's Timeout= for its lifetime -- the
   same combined recipe test_db_busy_fixture.cpp already documents,
   extended here since GlobalDataMapperPool() connections use the
   default connection string, not an explicit per-DataMapper one.

2. A genuine bug: DbBusyFixture's lock was released implicitly, by
   the object going out of scope at the very end of the TEST_CASE --
   after every worker thread was already joined. The lock was
   therefore held for the entire 32-way contention phase and never
   observably released while a worker was still waiting, independent
   of any busy-timeout value. Fixed by holding it in a unique_ptr the
   releaser thread itself resets after kLockHold.

With both fixed, 32 real threads racing SQLite's single writer lock
(rollback-journal mode) produce a severe, genuine thundering-herd --
raising the timeout well past ~2s doesn't change the mix (measured up
to 20s). kShortBusyTimeoutMs=2000 / kLockHold=150ms reliably produces
a small but real mix (1-2 succeeded, 30-31 failed, out of 32) across
8+ repeated runs including fresh-DB cold runs, with the no-double-apply
invariant holding on both branches every time.

Full account in
.superpowers/sdd/2026-08-16-kanban-backend/task-20-fix-round-1-report.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…e-journaling replays

Fix 1 (C1, Critical): OpenBoard/GetBoardState/GetEventsSince/GetActivity
performed no role check at all, so any authenticated principal (Login
mints a token for any username, no registration/membership check) could
read any project's full board, comments, and activity journal by id.
Adds requireRole(Role::Viewer) to GetBoardState/GetEventsSince/GetActivity,
and a new requireRoleOn(projectDbId, minimum) that OpenBoard calls against
the *target* project id (its own _projectIdStr isn't set yet at that
point) right after loadProjectById resolves the project and before
buildState returns any content. requireRole(Role) now delegates to
requireRoleOn using _projectIdStr, so every existing call site is
unaffected. Updates test_shared_instance_lifecycle.cpp's cross-project
OpenBoard case, which previously let the read through and only asserted
the subsequent write was Forbidden -- it now asserts OpenBoard itself is
rejected.

Fix 2 (C2, Critical): CreateTask, AddComment, and MoveTaskPosition's task
lookup never re-checked that the column/swimlane/task ids they were
handed actually belong to the attached project, unlike MoveTaskPosition's
existing destination-column check. A Member of project A could create a
task in project B's column, inject a comment onto project B's task (which
then surfaced in B's own board view), or move B's task into A's column
entirely. Adds requireTaskBelongsToProject and
requireSwimlaneBelongsToProject (siblings of the existing
requireColumnBelongsToProject) and calls them from all three actions
before their transactions begin. Adds four cross-tenant negative tests
plus a dedicated test for the previously-untested
swimlane-belongs-to-project check (ledger triage item #14).

Fix 3 (I1, Important): MoveTaskPosition's ledger-hit branch called
logAction() for a replay it didn't actually perform, journaling a
Succeeded outcome for an operation that only returned a stored result.
Design spec §4 had justified this as compensating for the framework's
own auto-append supposedly double-journaling ledger hits -- a live
FileActionLog capture during real RemoteServer dispatch (recorded in the
final review) shows this premise was wrong: the framework appends exactly
once. Removes the logAction call from the ledger-hit branch and the
now-unnecessary read-side collapse loop in GetActivity (which was also
independently lossy: it silently merged any two consecutive identical
actionType+payload entries, not just replays). Corrects design spec §4 to
record the framework's actual (verified) behavior.

Fix 4: adds the missing @param/@return Doxygen tags to AuthModel::execute
for consistency, and appends a correction to the ledger's Task 8 entry --
docs/CMakeLists.txt only scans include/morph, so this was never a CI
docs-gate blocker as previously claimed.

Verification: ladder_kanban_tests 270/57 (was 262/52), stable across
multiple random-order runs; ladder_common_tests 295/84 unchanged;
[stress] 12/1 and [offline] 94/3 unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslau Tamashevich and others added 5 commits August 17, 2026 23:36
…equired default

clang's -Wswitch-default (enabled under -Weverything -Werror on the
Linux clang-coverage / all-optional-features / Application-ladder CI
legs) requires an explicit default: label even on a switch that
already covers every enumerator -- confirmed this is the only failure
across all four failing jobs on PR #121's one CI run to date, and that
this exact tension (exhaustive switch needing a default anyway) is an
already-accepted pattern elsewhere in the ladder:
examples/pastebin/include/pastebin/units.hpp's UnitTraits<Unit>::meta
has the identical shape. CI's flag list already carries
-Wno-covered-switch-default, so adding the default arm satisfies
-Wswitch-default without tripping the opposite warning -- verified by
compiling a standalone repro of the exact switch shape against clang
22 (the CI compiler version) with the full CI flag list, both before
(fails on -Wswitch-default) and after (clean) this change.

No functional change -- the added default arm returns the same
fallback roleToString() already returned unconditionally before this
fix (Role::Viewer's string), for a code path every enumerator already
short-circuits before reaching.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…its::key

GCC's -Wuseless-cast (enabled on the "Application ladder" CI leg's
gcc-debug build, -Werror) correctly flagged this: ProjectId::operator*()
already returns std::int64_t directly (KANBAN_DEFINE_STRONG_ID's
generated dereference operator), so casting its result to
std::int64_t again was a genuine no-op. keyToString<K> is a template
taking any key type by const-ref, so passing the already-int64_t value
directly is equivalent and compiles identically under every other
build's flags.

This is the cosmetic Minor the Task 9 SDD ledger entry noted and
deferred to the final review ("redundant static_cast in the key
extraction") -- deferring it correctly assumed it was cosmetic under
every warning set exercised so far; GCC's stricter set (not previously
exercised against this branch until this CI run) makes it a hard
Werror failure instead, so it needs fixing now rather than staying
deferred.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…palBytes

Found by an independent adversarial /cpp-review pass after the branch's
own final-review fix round: SetMemberRole::validate()/RemoveMember::
validate() only checked `!principal.empty()`, with no upper bound --
unlike Login::username (already bounded via auth::isValidPrincipal) and
every other free-text field in this rung, which is static_assert-pinned
to its column's SqlAnsiString capacity.

ProjectRoleRecord::principal is a SqlAnsiString<64> column.
Light::SqlFixedString's constructor is noexcept and truncates silently
(std::min(N, s.size())) rather than throwing on an over-length value,
so an unbounded principal produced two real bugs:

- RemoveMember's lookup queries by the full, untruncated principal,
  which never matches the truncated stored row -- a role granted under
  an over-64-byte principal could never be removed through this action.
- A second SetMemberRole call with the same over-length principal fails
  to find (and delete) the existing truncated row via the same
  untruncated query, then collides with the real unique index on
  (project_id, principal) and throws -- not idempotent/update-safe.

Fix: both validate() methods now call auth::isValidPrincipal(principal)
instead of a bare emptiness check, mirroring Login::validate()'s
existing pattern exactly. Added a static_assert in
project_admin_model.cpp pinning kMaxPrincipalBytes to
ProjectRoleRecord::principal's actual capacity, matching
board_model.cpp's existing convention for every other bounded field.
Added two tests proving the rejection (not just its absence of a
crash): an over-length principal is rejected by both SetMemberRole and
RemoveMember with ValidationError, and the project's role table is
left unchanged.

Test count: 270/57 -> 274/59 (ladder_kanban_tests), all green, stable
across repeated runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…are/Lightweight#551

Both examples/common/CMakeLists.txt and examples/bank/CMakeLists.txt
pinned v0.20260625.0, the latest tagged release -- but
LASTRADA-Software/Lightweight#551 (BelongsTo<>'s implicit
value-construction path silently clearing _modified, breaking
Update() -- the bug this rung's MoveTaskPosition worked around) merged
to Lightweight's master after that tag was cut, with no newer tag
since. Pinned to master's tip commit SHA instead (GIT_SHALLOW switched
to FALSE, since a shallow clone of an arbitrary commit -- as opposed
to a tag/branch ref -- isn't reliably supported by all git server
configurations).

Verified: cleared the cached _deps checkout, reconfigured, confirmed
the fetched Lightweight source is genuinely at the pinned commit, and
rebuilt + reran the full ladder_kanban_tests suite against it (274
assertions / 59 test cases, all green, no regressions).

Cleaned up the workaround comment in board_model.cpp's
MoveTaskPosition that documented #551 as a correctness requirement for
assigning loaded parent records rather than raw FK integers -- with
#551 fixed, BelongsTo::operator=(S&&) (a bare key value) now marks the
field modified correctly, same as operator=(ReferencedRecord&), so
both forms are safe. The loaded-record assignment stays as-is (it
avoids a redundant re-fetch of rows already loaded by this function's
own ownership checks), but the comment now describes it as the style
choice it actually is rather than a forced workaround for a bug that
no longer exists.

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