Skip to content

ladder: rung 4 -- kanban - #121

Merged
Yaraslaut merged 76 commits into
masterfrom
ladder-kanban-impl
Aug 21, 2026
Merged

ladder: rung 4 -- kanban#121
Yaraslaut merged 76 commits into
masterfrom
ladder-kanban-impl

Conversation

@Yaraslaut

@Yaraslaut Yaraslaut commented Aug 17, 2026

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.

Deferred items — all but one now implemented

The original 20-task pass deferred three things. Two have since landed in this
branch:

  • Automation rules and task attachments — implemented in full
    (CreateRule/GetRules/DeleteRule, evaluateRules() firing on
    MoveTaskPosition and guarding on morph::journal::isReplaying(),
    RulesView.qml; attachment metadata actions, an HTTP side-channel
    AttachmentServer with 12 tests, and attachment UI in TaskDetailPopup.qml).
  • The client_pool.hpp/convergence.hpp interleaved-replay convergence
    test
    — now present as "Two clients' offline queues replaying interleaved
    converge on a valid board".

One remains, tracked separately:

  • process_pool.hpp (morph#140) — the QProcess client harness
    examples/TESTING.md assigns to rung 4. Every consumer the design spec names
    is a rung-8 test, and the ladder_<rung>_headless CMake target it needs
    already exists but builds nothing, so it is deliberately split out rather
    than shipped without a caller. morph#140 records the decision to make:
    build it when rung 8 needs it, or build it now against a real kanban
    client-crash test.

Also included

Merged in from #136 and follow-up work on this branch:

  • Application ladder / ASan+UBSan CI job — every rung's tests now run
    under AddressSanitizer and UndefinedBehaviorSanitizer. No rung test had ever
    run under a sanitizer before. The job asserts its own binaries are
    instrumented, because an uninstrumented sanitizer job passes unconditionally
    and reads as proof when it is the absence of proof.
  • morph#137Presenter::track() captured a bare this into its
    completion handlers, so a completion resolving after the presenter died wrote
    to freed memory (stack-use-after-scope, caught by the new job on its first
    green run). Fixed with the QPointer guard trackBound() already used, plus
    6 regression tests.
  • A stack-use-after-scope in test_kanban_offline.cpp — result flags
    declared after the Bridge that outlives them.
  • A data race behind both SQLite contention tests' intermittent failures
    std::vector<bool> threw, written concurrently by 32 worker threads. The
    bit-packed specialisation makes writes to distinct indices race, silently
    losing updates, so a board whose call had failed took the success branch of
    the verification loop. Verified A/B under TSan: 2 races before, 0 after.

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

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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>
Yaraslaut pushed a commit that referenced this pull request Aug 18, 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>
Yaraslaut pushed a commit that referenced this pull request Aug 18, 2026
Phased implementation plan covering the gaps found by an audit of
PR #121 against the rung's own Definition of Done: the GUI (never
built, only designed), client-side offline-stack wiring, three
missing tests (interleaved replay, permission revocation while
attached, WAL contention), a CI leg that actually runs the concurrent
stress test under ThreadSanitizer, the cascade-journaling decision,
and the two previously-deferred features (automation rules, task
attachments).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslau Tamashevich and others added 19 commits August 20, 2026 17:40
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.
Yaraslau Tamashevich and others added 16 commits August 20, 2026 17:40
… a causal parent, suppressed during replay

Implements CreateRule/GetRules/DeleteRule (Manager-only create/delete,
Viewer-or-above read) on BoardModel, mapping CreateRule's concrete
triggerColumnId to/from RuleRecord's general conditionField/conditionValue
storage shape (Task 13's deliberately-left-undone mapping).

Adds a minimal task_tags join table (task_id, tag) and TaskView::tags,
since RuleMutationType::AddTag/RemoveTag carry a bare tag name (no TagId
or tags table existed) -- the smallest concrete storage that makes a
fired rule observable.

Wires evaluateRules(TaskId, ColumnId, causalParentId) into the end of
execute(MoveTaskPosition), after the move's own commit. evaluateRules
checks morph::journal::isReplaying() first and no-ops during replay
(Phase 5's suppression). Each matching rule's mutation is applied via a
new registered action, ApplyTagMutation, journaled with causalParentId
set to the triggering move's own stable identity (minted from its
board_events row's autoincrement id, independent of LogEntry::seq per
design spec Sec 9). ApplyTagMutation is a real BRIDGE_REGISTER_ACTION
action (not a bare private helper) so its LogEntry independently
replays via morph::journal::replay()'s dispatcher.

Adds the two brief-specified tests proving the real mechanism (not
Task 12's hand-simulation): a rule firing via an actual MoveTaskPosition
adds a tag and journals a causal-linked entry, and replaying that
journal does not re-fire the rule (tag applied exactly once).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extends BoardBridge/BoardPresenter with createRule/getRules/deleteRule
Q_INVOKABLEs and a rules Q_PROPERTY, routing Task 14's CreateRule/
GetRules/DeleteRule through the same transport-only presenter pattern
every other BoardPresenter action already follows. Adds RulesView.qml,
structurally mirroring MembersView.qml (Phase 1): a flat ListView over
rules, a create form (trigger-column picker reusing board.columns +
mutation-type picker + tag-value field), and a per-row delete button.

BoardPresenter gained a _projectId member (set by openBoard()) purely
to satisfy CreateRule/GetRules' own validate() gate, which requires an
engaged projectId even though BoardModel::execute() never reads it
back (the handler's attach state names the board) -- not consulted for
RBAC or board selection.

ApplyTagMutation (Task 14's cascade-only action) is deliberately not
exposed anywhere in this surface, per Task 14's hand-off note.

Test: extended test_board_qml_bridge.cpp's surface-introspection case
and added a createRule/getRules/deleteRule round-trip case, mirroring
ProjectAdminBridge's listRoles/setMemberRole/removeMember test shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 15 review found two issues:

1. RulesView.qml was never reachable from the running app. Adds a
   "Rules" header button to BoardView.qml that opens RulesView inside
   a Popup, mirroring TaskDetailPopup's own open-on-demand mechanism
   exactly (same Popup property set: modal, focus, centered x/y).
   Wired into BoardView.qml rather than ProjectListView.qml (unlike
   MembersView's own precedent) because a rule's triggerColumnId
   picker needs the open board's own board.columns, only available
   once a board is already open. boardBridge is bound straight through
   to the same bridge instance BoardView.qml already holds; no new
   bridge/presenter surface was needed since Task 15 already exposed
   everything RulesView.qml uses.

2. docs/superpowers/specs/2026-08-17-kanban-gui-design.md line 320
   claimed automation rules have no backend surface, which Task 14
   (CreateRule/GetRules/DeleteRule, rule evaluation) and Task 15
   (RulesView.qml) have since made false. Updated to state the current
   status; the attachments half of that line is unchanged since Phase
   7's backend genuinely does not exist yet.

Updated test_gui_qml_smoke.cpp's comments to note the existing 'board
view loads standalone' case now also exercises RulesView.qml (no new
TEST_CASE needed, since it's exercised transitively).

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

Metadata-only task attachments (README build-order step 8): AddAttachment
records a task-scoped AttachmentRecord after a separate HTTP side channel
(a later task) has already uploaded bytes and returned a storageKey;
GetAttachments lists a task's attachments; RemoveAttachment deletes the
metadata row (not the underlying bytes).

AttachmentRecord mirrors CommentRecord's exact shape (task-scoped child
table, BelongsTo<&TaskRecord::id>). AddAttachment/RemoveAttachment gate at
Role::Member, GetAttachments at Role::Viewer -- the same RBAC bar
AddComment/GetBoardState already use, since attachments are task-content
like comments, not board administration like CreateRule/DeleteRule
(Role::Manager).

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

Task 16 review finding: AddAttachment::validate() only checked non-empty
on filename/contentType/storageKey, never their length against the
SQL column capacity (Varchar(255)/Varchar(127)/Varchar(255)). Every
other bounded-SqlAnsiString-backed DTO field in this codebase pairs its
length bound with a validate() check and a static_assert tying the DTO
constant to the column's own .capacity() -- this is the same class of
bug already found and fixed once before in SetMemberRole/RemoveMember's
principal check: an over-length value silently truncates on write, and
a later equality lookup against the caller's untruncated string then
never matches the truncated stored row.

Adds kMaxAttachmentFilenameBytes/kMaxAttachmentContentTypeBytes/
kMaxAttachmentStorageKeyBytes (255/127/255) to attachment_dto.hpp,
wires them into AddAttachment::validate(), and adds three matching
static_asserts in board_model.cpp tying each constant to
AttachmentRecord's actual field capacity. Extends the existing
AddAttachment/GetAttachments/RemoveAttachment validate() test with
over-length-rejection assertions for all three fields plus an
at-exactly-max-length control case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…with its own size bound

Adds kanban::http::AttachmentServer, a hand-rolled HTTP server over
QTcpServer/QTcpSocket (no QHttpServer dependency exists anywhere in this
tree) implementing the two routes README build-order step 8 calls for:

- POST /attachments: Authorization: Bearer <token> required, raw body
  bytes, X-Attachment-Content-Type header. Returns {"storageKey": "..."}
  on success -- the opaque key Task 16's AddAttachment action is then
  called with to commit the metadata row.
- GET /attachments/{storageKey}: same auth requirement, streams the
  stored bytes back with the recorded content type, or 404 if the key
  names no stored blob (including the dangling-metadata-row case: a
  storageKey committed via AddAttachment that no upload ever produced).

Security-relevant design choices (see task-17-report.md for full
reasoning): hand-rolled listener over QHttpServer (smaller, fully
auditable, no new Qt module for two fixed routes); storageKey is a random
64-hex-char token (std::random_device), not a content hash, to avoid a
dedup-confusion/probing-oracle surface; storageKey is validated to that
exact shape before ever reaching a filesystem path, closing off path
traversal in one check; authentication happens before any route logic,
size check, or body byte is read; the size bound is enforced both against
the declared Content-Length and against the running total actually
received, so a dishonest Content-Length can't be used to bypass it; one
request per connection with Connection: close, no keep-alive/chunked
encoding.

Wired into src/server/main.cpp alongside the existing QtWebSocketServer,
constructing its TokenVerifier from the exact same tokenSecret/hmacSha256
App's own KanbanAuthorizer already uses -- not a second, independently-
sourced secret. New KANBAN_ATTACHMENT_PORT env var (default 8769),
parsed with the same std::from_chars discipline as the existing
KANBAN_PORT.

Tests (examples/kanban/tests/test_attachment_server.cpp): 10 new test
cases covering valid upload, oversized upload (413, including a
dishonest-Content-Length variant caught during review), GET of an
existing key, GET of a nonexistent key, a malformed/garbage-input
robustness test (9 adversarial byte strings, no crash/hang), the
dangling-metadata-row scenario against a real BoardModel::AddAttachment
call, and three unauthenticated/forged-token rejection variants. No
MORPH_BUILD_FUZZERS harness added (that apparatus is Clang/libFuzzer-only
and morph-framework-scoped; a thorough Catch2 malformed-input test covers
the same robustness requirement for this app-level parser instead).
docs/spec/security.md was not touched -- it documents morph's own
session/RemoteServer trust model and has no side-channel enumeration
list this app-level example server belongs in.

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

AttachmentServer::handleRequest authenticated (verified the bearer token
was validly signed and unexpired) but never authorized: any principal
holding a valid token for ANY project could GET any attachment blob by
storage key, softened only by the key's unguessability rather than a real
authorization boundary. This contradicted docs/spec/security.md's "Opaque
model ids" section.

Fix: handleRequest now captures the verified SessionToken (not just a
bool) from TokenVerifier::verify(), and GET /attachments/{storageKey}
resolves the key's owning db::AttachmentRecord -> db::TaskRecord ->
project, then requires the verified principal hold at least Role::Viewer
there -- mirroring BoardModel::execute(const GetAttachments&)'s own
requireRole(Role::Viewer) + requireTaskBelongsToProject gate.
loadCallerRole is duplicated (not shared/exported) from board_model.cpp,
following that file's own established "design spec §3: not shared code,
each model gets its own copy" convention (project_admin_model.cpp already
has an independent second copy). A nonexistent storageKey and an
existing-but-unauthorized one both return 404, so a caller can never
distinguish "doesn't exist" from "exists but you have no access."

POST /attachments is left as documented-gap, not an added check: it mints
a fresh storageKey with no AttachmentRecord yet to resolve ownership
from, so there is nothing meaningful to authorize at upload time; the
real boundary for committing an attachment is AddAttachment's existing
requireRole/requireTaskBelongsToProject gate, and the real boundary for
reading one is this GET fix. Documented explicitly in the class doc
comment.

New test: a principal with a validly-signed token for her own,
completely separate project gets 404 (not 200) attempting to GET another
project's committed attachment. The existing positive-control download
test is rewritten to actually commit the storageKey via AddAttachment
first (previously it downloaded an uncommitted upload directly, which
now correctly 404s, since an uncommitted blob has no project to check a
role against). The pre-existing never-uploaded-404 test gains a
DbFixture, since every GET now performs an authorization DB lookup.

ctest -L ladder-kanban: 119/119 passed (was 118; +1 net-new test case).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires Task 16's AddAttachment/GetAttachments metadata actions and Task
17's AttachmentServer HTTP side channel into the GUI:

- BoardPresenter gains addAttachment()/getAttachments(). addAttachment()
  returns its own Completion<Ack> (not a shared signal) so a bridge-level
  upload can chain its own outcome without cross-attribution between
  overlapping calls, mirroring moveTaskForReplay/getEventsSinceForPolling.
- BoardBridge gains an `attachments` Q_PROPERTY plus uploadAttachment(),
  downloadAttachment(), getAttachments(), and setAttachmentServerUrl()
  Q_INVOKABLEs. uploadAttachment() reads a local file, POSTs it to
  AttachmentServer via QNetworkAccessManager (X-Attachment-Content-Type
  header, no multipart, per the server's own documented protocol), then
  commits its metadata via AddAttachment on success. downloadAttachment()
  GETs a storageKey's bytes and writes them locally, treating a 404 (the
  server's real per-project authorization gate, not just authentication)
  the same as any other failure via failed(QString). The bearer token is
  read from Bridge::defaultSession().token -- the same session Login
  already installs -- rather than inventing a new auth-storage mechanism.
- TaskDetailPopup.qml gains an attachment list and "Attach file"/
  "Download" buttons backed by QtQuick.Dialogs' FileDialog, alongside the
  existing comment section.
- gui/main.cpp gains an --attachment-server <url> flag (mirroring --server),
  defaulting to the server's own KANBAN_ATTACHMENT_PORT default (8769)
  when --server is given; left unset in Local mode, which runs no
  AttachmentServer of its own.
- kanban's CMakeLists.txt links Qt6::QuickDialogs2 onto ladder_kanban_qml
  (not the consuming gui/tests executables -- qt_add_qml_module's own
  import-scanning needs the plugin visible as a dependency of the QML
  module itself) so FileDialog resolves at runtime, not just at AOT
  compile time.

Test: extends test_board_qml_bridge.cpp with an end-to-end upload ->
commit -> list -> download round trip against a real AttachmentServer,
and a no-server-configured failure case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 18 review finding fix: the only pre-existing failed()-signal test for
attachments tested upload's pre-flight guard (no attachment server
configured), which never issues an HTTP request. Nothing exercised
downloadAttachment() against a real, running AttachmentServer returning a
genuine 404, for either of Task 17's two collapsed-to-one-status-code
causes.

Adds two tests to test_board_qml_bridge.cpp, both driving a real
kanban::http::AttachmentServer:

- "BoardBridge::downloadAttachment reports failed() for a storageKey that
  was never uploaded (a real 404 from a real AttachmentServer)" -- mirrors
  test_attachment_server.cpp's own nonexistent-key 404 case one layer up at
  the bridge; asserts failed() fires, attachmentDownloaded does not, and no
  file is left at the destination path.

- "BoardBridge::downloadAttachment reports failed() the same way for a
  storageKey that belongs to a DIFFERENT project the caller has no role on
  (authenticated, not authorized)" -- mirrors test_attachment_server.cpp's
  cross-tenant regression test, wired through two real BoardBridge
  instances (alice uploads and commits an attachment; mallory, a separately
  signed principal with no role on alice's project, tries to download it).
  Proves the GUI collapses both causes to the same failed() behavior, per
  the server's deliberate one-status-code security design.

Both reuse this file's existing kAttachmentTestSecret/
freshAttachmentStorageDir/makeAuthedRigWithToken/seedProject helpers --no
new scaffolding needed.

Full kanban suite: 123/123 passed (121 pre-existing + 2 new), no
regressions.

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

- Test 19's own re-read of README.md's Definition of Done against every
  prior task's shipped work found two real gaps no task-scoped review
  caught (each is cross-cutting, not owned by any single task):
  - The offline tests never asserted the framework's own morph::observe
    metrics (queueDepth, reconnectAttempts, reconnectOutcome), though the
    DoD explicitly requires it. Added a dedicated test
    (test_board_offline_bridge.cpp) installing a MetricSink via
    ScopedObserveOverride around the existing offline queue/reconnect
    flow and asserting all three metrics fire.
  - The 'demo scripted' DoD claim for the kill-the-network scenario was
    unsubstantiated -- kanban has no --seed CLI path (LADDER.md's
    ladder-wide convention), only tests. Corrected the wording to state
    this accurately rather than claim a demo that doesn't exist.
- The HTTP attachment side channel's 'joins the fuzz corpus' claim was
  also stale: Task 17 deliberately used a dedicated adversarial Catch2
  test instead (no MORPH_BUILD_FUZZERS harness targets HTTP parsing).
  Corrected to describe the actual, already-reviewed substitution.
- Updated the 'Deferred within this rung' section (steps 6/8 were
  marked deferred; both are now implemented) and the top status line
  to reflect current, complete state.

124/124 kanban tests passing (123 prior + 1 new metrics test, verified
non-flaky across 5 repeated runs).

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

The upload header X-Attachment-Content-Type was stored unvalidated, written
verbatim to a .contenttype sidecar file, read back verbatim, and
interpolated directly into the GET response's Content-Type: header.
parseHeaders only splits on \r\n, so a header value containing a bare \n
(no preceding \r) survives parsing intact as part of the value -- and a
lenient HTTP client/intermediary that honors bare-LF line termination could
turn an upload like `X-Attachment-Content-Type: text/plain\nX-Injected:
evil` into an injected header on every subsequent GET of that attachment.

Add isPlausibleMediaType()/sanitizedContentType() in attachment_server.cpp:
a strict type/subtype allowlist ([A-Za-z0-9!#$&^_.+-] per half, both
non-empty, one '/'), capped at kanban::kMaxAttachmentContentTypeBytes (the
existing Task 16 bound from attachment_dto.hpp, reused rather than
duplicated). Anything that fails substitutes the existing default
application/octet-stream -- fails closed rather than rejecting the upload,
since content type is convenience metadata, not a security-critical field
in its own right.

Applied at both the point the header is captured on upload (before it is
even kept on ConnectionState, let alone written to the sidecar file) and
the point the sidecar is read back for GET (defense in depth: the sidecar
is a plain file on disk that could in principle be written by other means).

Adds a regression test that uploads with a bare-LF-bearing
X-Attachment-Content-Type, downloads it back, and asserts against the raw
response bytes that no injected header line appears anywhere and the
Content-Type falls back to the safe default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Running ladder_kanban_tests.exe as one process (not via ctest, which runs
each TEST_CASE in its own process via catch_discover_tests) crashes
reproducibly at a location that shifts with Catch2's test-run order --
the signature of process-wide state leaking across TEST_CASEs.

Not a regression from this branch: examples/common/testkit/db_fixture.hpp
(the suspected culprit -- its ensureConnectionConfigured() gates
process-wide SqlConnection/MigrationManager singleton setup behind a
static-once guard never reset per TEST_CASE) is untouched by any commit on
this branch; its last change predates this plan (rung 0, 557b892). This
plan roughly doubled the TEST_CASE count compiled into the single
ladder_kanban_tests binary, which is what made the pre-existing bug newly
observable.

CI is unaffected: cmake/morph_add_rung.cmake's catch_discover_tests(...)
call registers one CTest entry per TEST_CASE, each launched by CTest as its
own separate process, so the singleton state never survives across tests
there.

Documents the finding for human triage into a tracked issue; does not
attempt to fix the underlying testkit bug, which is out of scope for a
kanban-focused plan and deserves its own scoped investigation.

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

Task 18's BoardBridge::uploadAttachment/downloadAttachment use
QNetworkAccessManager (board_qml_bridge.hpp/.cpp) to talk to Task 17's
HTTP attachment side channel. This target only ever picked up
Qt6::Network *transitively*, by linking morph::ladder_kanban_lib
(morph_add_rung.cmake's own conditional block for that) -- and
ladder_kanban_lib does not exist at all under Emscripten (persistence
is server-side only for a WASM client). Native builds passed by
accident through that transitive chain; the WASM CI job
('Build the ladder's WASM clients') failed with
"'QNetworkAccessManager' file not found", since gui_lib had no
transitive path to Qt6::Network there at all.

Links Qt6::Network directly and unconditionally onto
ladder_kanban_gui_lib, gated only on MORPH_BUILD_QT (matching Task 17's
identical gating for ladder_kanban_lib's own Qt6::Network need) --
this dependency is needed by shared presenter/bridge code both the
desktop and WASM clients link, on every platform, not just natively.

Verified: full kanban test suite still 125/125 after reconfigure +
rebuild on the native (clangcl-release) tree. Could not run the actual
Emscripten toolchain locally; confirmed via the CI workflow
(wasm-ladder.yml) that MORPH_BUILD_QT=ON is set there (so this fix's
guard condition is true) and that Qt6::Network is part of qtbase's base
install (unlike qtwebsockets, an explicitly-listed add-on module), so
no CI workflow change is needed alongside this CMake fix.

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

Kanban/ThreadSanitizer, Linux/clang-coverage, and Linux/all-optional-
features(clang) all failed on the same underlying bug: a heap-use-
after-free in QtExecutor::post()/CompletionState::setException,
confirmed by TSan and reproduced as a plain SIGSEGV in the two
uninstrumented builds.

Root cause: QtExecutor::post() (qt_executor.hpp) uses
Qt::QueuedConnection -- it enqueues a callback on the Qt event loop and
returns immediately, it does not run it. ThreadPoolExecutor's
destructor joins every worker thread, which guarantees every post()
call was *made* before it returns, but not that the resulting event
was *pumped*. Bridge::executeVia (bridge.hpp) chains two Completion
objects per action, so a single failed action can enqueue a *second*,
nested post() from inside the first posted callback -- invisible to a
caller's own pumpUntil(outstanding == 0), which only tracks the outer
completion. BackendRig could observe done and tear itself down,
freeing _qtExecutor, while that inner post's callback was still
sitting undelivered in the Qt event queue; when it finally ran, it
touched freed memory.

Fix: BackendRig::~BackendRig() now resets _workerPool explicitly
(forcing every pool-issued post() to have already happened) and then
drains the Qt event loop for a few bounded slices -- long enough for a
nested post to both arrive and run -- before the rest of member
destruction (including _qtExecutor) proceeds. This mirrors
QtWebSocketServer::closeGracefully()'s own established
processEvents-drain pattern, just applied to the worker-pool/executor
pair instead of the socket server.

Also drops a redundant static_cast<int> in
test_board_concurrent_drag.cpp that GCC's -Wuseless-cast (clang has no
equivalent warning) correctly flagged as dead code: the expression was
already int-typed before the outer cast.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Kanban/ThreadSanitizer's CI leg (Task 10, this branch) is the first CI
job to ever run kanban's Qt-linked concurrent tests under real
ThreadSanitizer instrumentation, and surfaced two framework-level
bugs neither specific to kanban:

1. A heap-use-after-free in QtExecutor::post()/Completion's nested
   post chain, racing BackendRig's teardown -- fixed on this branch
   (917ea54) and verified via a standalone repro (5/5 crashes without
   the fix, 10/10 clean with it, at kanban's own real concurrency
   scale). Filed as #127 for the framework
   maintainers, since the underlying Completion/QtExecutor lifetime
   gap affects every consumer, not just this test harness.

2. A second, distinct race that survives fix #1: 165 ThreadSanitizer
   warnings centered on Qt's own internal QCallableObject/invokeMethod
   machinery, plus a load-bearing discovery that the kanban-tsan CI
   job's own comment (claiming this test has "no Qt/GUI involvement")
   is factually wrong -- BackendRig's Mode::Local unconditionally
   builds a real QtExecutor. Not resolved here: two repro attempts
   (a 200-chain scale-up of fix #1's own repro, and a targeted
   publishResult fan-out repro) both ran clean, but neither had
   ThreadSanitizer available locally to confirm the race itself, only
   to rule out a plain crash. Filed as
   #128 with full evidence and suggested next
   steps (confirm/rule out Qt's own lack of TSan instrumentation;
   fix or retire the CI job's incorrect premise).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tId instead of asserting

Linux / all optional features (gcc) failed with a SIGABRT: libstdc++'s
hardened build asserts inside std::optional<int64_t>::operator*() when
the optional is disengaged. Root cause: BoardBridge::openBoard()
parses an unparseable QString into a default-constructed, disengaged
ProjectId{} (board_qml_bridge.cpp's own parseId<ProjectId>(), which
returns IdT{} rather than throwing, per its own doc comment). That
action reaches morph::bridge::BridgeHandler::execute() before
BoardModel::execute(OpenBoard)'s own hasValue()-guarded validation
ever runs -- ActionKeyTraits<OpenBoard>::key() computes the routing
key that attach relies on, earlier in the dispatch pipeline, and used
to dereference action.projectId unconditionally.

bridge.hpp's own BridgeHandler::execute() already wraps key extraction
in a try/catch specifically so a throwing ActionKeyTraits::key() routes
to the caller's onError() rather than escaping (see that call site's
own comment) -- this fix uses exactly that sanctioned seam, throwing
kanban::ValidationError (mirroring BoardModel::execute(OpenBoard)'s
identical "projectId is required" rejection for the case where the
key never even reaches that check) instead of dereferencing.

"BoardBridge relays failed() on a bad projectId" (test_board_qml_bridge.cpp)
already exercised this exact path and expected a clean failed() signal;
it just happened to only crash under libstdc++'s hardened assertions
(the gcc CI leg), not under every other job's libc++/MSVC build, so it
went undetected until this branch's CI matrix widened enough to catch it.

Added a dedicated, DB-free regression test
(test_board_model.cpp's "ActionKeyTraits<OpenBoard>::key() rejects a
disengaged projectId instead of asserting") that exercises
ActionKeyTraits<OpenBoard>::key() directly -- a pure function, no
database needed -- proving the fix without depending on a live DB
connection (this session's local environment has an unrelated,
pre-existing SQL Server credential gap that blocks every DB-touching
kanban test; this new test sidesteps it entirely and passes cleanly).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslau Tamashevich and others added 3 commits August 20, 2026 18:03
…es morph#128's blocking symptom)

morph#128 found the kanban-tsan CI job's own premise false: its comment
claimed test_kanban_stress.cpp ran with 'no Qt/GUI involvement', but
BackendRig's Mode::Local unconditionally constructs a real
morph::qt::QtExecutor for client-facing callback delivery, and all 165
ThreadSanitizer warnings the job produced bottomed out in genuine
Qt-internal frames (QMetaObject::invokeMethod, QCallableObject,
QObject::event) reached through it -- undetectable as real bugs or
false positives from outside a TSan-instrumented Qt build (which this
CI's prebuilt Qt package is not).

Rather than resolve that ambiguity (would need a TSan-instrumented Qt
build, or a scoped suppressions file accepting unverified risk), the
test is rewritten to drive BoardModel through a bare
morph::bridge::Bridge wrapping a morph::backend::LocalBackend
directly -- the exact pattern tests/test_concurrency_invariants.cpp's
own concurrent-dispatch test already uses -- with a two-line
InlineExecutor (post(fn) { fn(); }) standing in for QtExecutor on the
client-callback side, and a plain waitUntil busy-poll instead of
pumpUntil/awaitQt. BoardModel's own requireRole/session checks and
ModelKeyTraits<BoardModel>'s shared-per-project instance semantics are
backend-agnostic (session context read directly from
morph::session::current(); shared-instance keying is a Bridge-level
mechanism, registerModelShared), so what design spec §8 actually
requires this test to check (dense/unique positions, no task lost or
duplicated under concurrent MoveTaskPosition calls) is unchanged --
only the plumbing that delivers callbacks changed. Zero Qt frames
remain anywhere in this test's call graph, making the kanban-tsan
job's 'no Qt/GUI involvement' premise genuinely true.

This does not resolve morph#128's own open framework question (real
bug vs. Qt-instrumentation gap) -- it sidesteps it for this one test,
leaving the issue open upstream for whoever audits other Qt-linked
concurrent code under a real sanitizer. Updated
docs/superpowers/plans/2026-08-19-kanban-tsan-ci-findings.md,
examples/TESTING.md, and .github/workflows/ci.yml's own comment to
match.

Verified locally: ladder_kanban_tests full suite passes (122 test
cases, 875 assertions), the stress test itself stable across 4 repeat
runs (29 assertions each), zero compiler warnings under clang-cl
-Weverything -Werror.

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

The rewritten test (c48514c) genuinely ran and completed under real
ThreadSanitizer instrumentation in CI -- no TSan warnings at all, a
plain REQUIRE failure: the 200-action (4 clients x 50 actions) workload's
outstanding-completions count never reached 0 within the original 20s
budget, finishing at ~32s instead. Root cause: this rewrite delivers
.then()/.onError() directly on whichever real ThreadPoolExecutor worker
resolves each completion (InlineExecutor), unlike the original Qt-based
version's client-side callback delivery -- combined with TSan's own
well-documented 5-15x instrumentation overhead, 20s (never actually
exercised against real TSan overhead before, since CI never set
MORPH_LADDER_DEADLINE_MS for this job either) was too tight.

Raised to 90s -- comfortably past the observed ~32s, generous enough for
slower CI runners, while still catching a genuine hang/deadlock well
within the job's own timeout. Re-verified locally: stable across 3
repeat runs, full kanban suite unaffected (875 assertions, 122 test
cases).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ed's empty-env arm

`codecov/patch` was failing this PR at 94.54% against a 97.23% target. Every
non-hit line in the diff was in `action_driver.hpp`: one missed line plus two
partial branches, all three tracing back to the same shape.

`next()` walked all N weight ranges and then fell through to
`return _generators.front().generate()`. `pick` is drawn from
[0, _totalWeight) and the weights sum to `_totalWeight`, so some generator
always matched first: the fallback could not be reached by any input, and
neither could the loop's own "condition went false" arm. llvm-cov scored the
first as a permanently-missed line and the second as a partial branch, and no
test could have fixed either.

Walk only the first N-1 ranges instead and let the last generator absorb the
remainder. Running off the end of the loop is now the ordinary "last generator
won" outcome rather than an error, so the fallback disappears and both arms of
the loop are reachable. The selection is unchanged for every input: a pick that
matched no earlier range already had to land in the final generator's range.

The remaining partial was `resolveSeed`'s `env != nullptr && *env != '\0'`
guard. Its second conjunct only ever evaluated true, because no test set
MORPH_STRESS_SEED to an empty value -- which an `export MORPH_STRESS_SEED=` in
a CI shell produces easily, and which without the emptiness check would reach
`std::stoull("")` and throw rather than falling back to the caller's default.

Adds a test for that empty-but-present case, and one that weights the last
generator heavily so a single script exercises both an early break and a run to
completion.

action_driver.hpp is now 100% of lines, regions, branches and functions
(llvm-cov, clang-coverage build); ladder_common_tests passes 367 assertions in
89 cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Yaraslaut and others added 3 commits August 21, 2026 14:14
* ci: run every rung's tests under AddressSanitizer and UBSan

No rung test has ever run under a sanitizer. `ladder-tests` builds plain
gcc-debug, and the `linux-sanitizers` matrix deliberately skips the ladder --
only its `clang-coverage` leg sets `MORPH_BUILD_LADDER`. So every rung's
models, presenters and QML adapters, which is precisely the code the ladder
exists to exercise, were compiled and run with no memory or UB instrumentation
anywhere in CI.

Adds `ladder-sanitizers` ("Application ladder / ASan+UBSan"): configures
`--preset clang-asan` with `MORPH_BUILD_QT=ON`, `MORPH_BUILD_LADDER=ON`,
`MORPH_LADDER_RUNGS=all` and runs `ctest -L ladder -LE stress`. One preset
covers both sanitizers -- `apply_sanitizers(<target> asan)` compiles with
`-fsanitize=address,undefined` -- so a separate ubsan leg for the ladder would
re-run a strict subset. It reuses `ladder-tests`' changed-paths filter
verbatim, since the two build the same tree and differ only in
instrumentation, and excludes `stress` for the same reason `ladder-tests`
does, compounded by ASan's shadow-memory overhead.

TSan is deliberately not included. A rung's tests drive Qt on every path, and
against an uninstrumented system Qt that yields warnings bottoming out in
Qt-internal frames that cannot be classified as real races or false positives
from outside a TSan-instrumented Qt build -- morph#128 hit exactly that, 165
warnings deep. The resolution there was to rewrite the one test that mattered
to construct no QtExecutor at all and run only it under TSan, which is what
`kanban-tsan` does. Thread-sanitising a rung means repeating that pattern per
test, not adding a blanket tsan leg.

The job asserts its own binaries are instrumented before trusting a green run.
An uninstrumented sanitizer job is worse than no job: it runs the full suite,
costs the full runtime and always passes, so a missing `apply_sanitizers()`
call would read as "rungs are ASan-clean" rather than "rungs were never
checked". That is not hypothetical -- with this branch's own
`AF_SANITIZER` blocks reverted, `-DAF_SANITIZER=asan` instruments
`morph_tests` and nothing whatsoever under `examples/`. The check greps each
`ladder_<rung>_tests` binary for `__asan_` references and fails the job if any
lacks them (measured: 7395 with the wiring in place, 0 without).

`ASAN_OPTIONS=detect_leaks=0` because LeakSanitizer reports allocations Qt's
platform plugins and QML engine keep for process lifetime, which this repo
cannot fix or meaningfully suppress per-frame; the memory-error and UB checks
stay fully on.

Verified locally against the pastebin rung (clang 22, Qt 6.11): all 54
`-L ladder -LE stress` cases pass under ASan+UBSan in 25s, with the binary
confirmed instrumented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: install Qt via aqtinstall in ladder-sanitizers, not apt

The first run of this job failed at configure:

  CMake Error at examples/common/CMakeLists.txt:27 (find_package):
    Could not find a configuration file for package "Qt6" that is compatible
    with requested version "6.5".
      /usr/lib/x86_64-linux-gnu/cmake/Qt6/Qt6Config.cmake, version: 6.4.2

examples/common requires Qt 6.5+ unconditionally and Ubuntu 24.04 ships
6.4.2, so `apt-get install qt6-base-dev qt6-websockets-dev qt6-tools-dev`
cannot satisfy it. Every other Linux job that builds the ladder already
installs Qt ${{ env.QT_VERSION }} (6.8.1) through jurplel/install-qt-action
and documents this exact gap; this job was written against the older apt-based
shape those jobs used before they moved off it.

Drops the three qt6-*-dev packages and adds the same install-qt-action step
kanban-tsan uses -- the closest analogue, being the other clang + ladder +
sanitizer leg -- rather than keeping apt's Qt alongside aqtinstall's, which
would leave two Qt6 installs for find_package() to choose between. Also picks
up libyaml-cpp-dev/libzip-dev, which the apt line was missing relative to the
other ladder-building jobs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* gui: guard Presenter::track()'s completion handlers against the presenter dying first

Fixes morph#137, the stack-use-after-scope the new ASan+UBSan ladder leg found
on its first green build.

`track()` captured a bare `this` into both the `.then` and `.onError`
handlers. A `Completion` always resolves *through* the executor -- even
`Local` mode's immediate resolution is posted, not delivered inline
(`CompletionState<T>::attachThen`) -- so the presenter can already be gone
when either handler runs, and `finishOne()`'s `_inFlight.fetch_sub(1)` then
writes to freed memory.

`trackBound()`, the method immediately above it in the same file, already
guards exactly this with a `QPointer<Presenter>` and a comment explaining
why. `track()` simply never got the same treatment. This applies the pattern
the file already establishes rather than inventing a second one.

The guard covers `onOk`/`onErr` as well as `finishOne()`. That is not
belt-and-braces: a subclass's callback captures *its* `this`, so running it
against a destroyed presenter is the same use-after-free one frame further
out. Reverting only the `finishOne()` half still aborts under ASan inside
`ProbePresenter::bump`'s own onOk lambda, writing `lastResult` through a dead
`this`. `self` is re-checked after the callback returns because the callback
itself may destroy the presenter.

How it was reached in practice: a presenter is constructed *from* a rig
(`rig->bridge(0)`, `rig->executor()`), so it must be declared after it and is
therefore destroyed *before* it. `BackendRig`'s destructor then deliberately
pumps the Qt event loop to flush queued posts, resolving completions into a
presenter that is already gone. No test can reorder its way out of that.

Adds two regression tests mirroring the existing `trackBound()` destroyed-first
case -- one per handler branch, since a fix covering only the success path
would leave the error path corrupting memory where it is hardest to notice.
Verified A/B on the clang-asan build: with the fix, 11 presenter cases / 40
assertions pass with no sanitizer report; with `presenter.hpp` reverted, the
new case aborts with `AddressSanitizer: stack-use-after-scope`. The kanban
rung's full `-L ladder -LE stress` suite reports no sanitizer findings at all
(the one remaining failure there is morph#134, macOS-only and unrelated).

This is the per-site boilerplate morph#138 proposes to make unnecessary: the
framework offers no lifetime-binding API, so five classes hand-roll a liveness
token across 23 call sites and a sixth spelling gets forgotten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* testkit: cover track()'s post-callback liveness re-check on both handler branches

`codecov/patch` flagged the previous commit at 73.33%: 15 patch lines, no
missed lines, but four partial branches -- both of track()'s post-callback
`if (self)` re-checks, on both the onOk and onErr paths. Their false arm is
only reachable when the callback itself destroys the presenter, which no test
did.

That arm is the whole reason the re-check exists, so it is worth a test rather
than a coverage waiver. A handler ending its own presenter's life is ordinary:
closing the screen a completion just reported into is exactly the kind of thing
a real callback does. Without the second check, `finishOne()` would run on the
object the callback had already destroyed -- the same use-after-free this
branch set out to fix, one step later in the sequence.

Adds `bumpAndDestroySelf()`/`failAndDestroySelf()` probes that reset the
`unique_ptr` owning the presenter from inside onOk and onErr respectively, and
a test for each. Destroying from inside the handler is safe because the lambda
lives in the completion's own state rather than in the presenter, so it
outlives the object it just destroyed.

13 presenter cases / 44 assertions pass on the clang-asan build with no
sanitizer report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* testkit: cover track()'s catch-block liveness re-check on both handler branches

The previous commit took `codecov/patch` from 73.33% to 86.66%; the two
remaining partials are track()'s *catch-block* `if (self)` guards, one per
handler branch. Reaching them needs a callback that both destroys the
presenter and then throws, which no test did.

Adds `bumpDestroySelfAndThrow()`/`failDestroySelfAndThrow()` and a case for
each. This is where the two contracts in track() cross: the documented
exception-safety rule says finishOne() must still run when a callback throws,
and the liveness guard says it must not run against a destroyed presenter.
When a callback does both, only the re-check inside the catch block keeps
those compatible.

Deliberately not restructured into an RAII scope guard, which would collapse
all four `if (self)` checks into one and cover itself with the tests already
present. That would change exception semantics rather than just tidy them: a
throwing `idle()` slot currently propagates out of the catch block, but from a
destructor during unwinding it would call std::terminate. These lines carry
explicit, documented exception-safety reasoning, so the coverage gap is closed
with tests rather than by rewriting the contract underneath them.

15 presenter cases / 48 assertions pass on the clang-asan build with no
sanitizer report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* kanban: outlive the Bridge with the dropped-reply test's own result flags

Second finding from the ASan+UBSan ladder leg, in test code this time:

  ERROR: AddressSanitizer: stack-use-after-scope
  WRITE of size 1 ... test_kanban_offline.cpp:207
    in CATCH2_INTERNAL_TEST_47()::$_2::operator()(exception_ptr const&)

"Dropping MoveTaskPosition's reply frame and retrying is exactly-once"
declares `firstResolved`/`firstFailed` next to the `execute()` call that uses
them, which puts them *after* the FaultProxy, QtWebSocketBackend, QtExecutor
and Bridge -- so they are destroyed first. The whole point of the test is that
the dropped reply leaves that Completion permanently unsettled, and tearing
the Bridge down at end of scope fails it, running the `.onError` handler
attached at line 207. By then both bools are dead stack slots and the handler
writes into them.

Hoists the two declarations above every object that can outlive them. No
behavioural change -- the assertions and the scenario are untouched -- and a
comment records why their natural placement is the wrong one, since the next
person to add a flag to this test will reach for the same spot.

Same hazard and same cause as morph#137, one level out: a callback outliving
the frame it captured by reference, with nothing in the API to say so. Both
were invisible in every unsanitized build. That the identical shape surfaced
twice in one day, in unrelated code, is the argument morph#138 makes -- the
default spelling is the unsafe one.

Verified on the clang-asan build: the case passes, 13 assertions, no
sanitizer report.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Linux / all optional features (gcc)` failed intermittently on the two 32-way
contention tests -- once per journal mode, on a different board index each
time:

  CHECK( movedCount == 1 )
  with expansion: 0 == 1
  with messages: succeeded.load() := 1  failed.load() := 31  i := 9

The cause is `std::vector<bool> threw(kBoards, false)`, written concurrently by
all 32 worker threads. For every other element type, writes to distinct
indices are independent; `vector<bool>` is the bit-packed specialisation, so
neighbouring elements share an underlying word and those writes become
concurrent read-modify-writes of one object -- a data race that silently drops
updates.

`succeeded`/`failed` are `std::atomic<int>` and stayed accurate, which is why
the counters read 31 failures while `threw[]` under-reported them. A board
whose call had failed therefore took the *success* branch of the verification
loop, which demands the move be applied -- and correctly it was not. The
assertion was right; the bookkeeping feeding it was corrupt.

Switches both to `std::vector<char>`. Verified A/B on a TSan build of
ladder_kanban_tests, running only these two cases:

  vector<bool>: 2 data races, both `__bit_reference::operator=(bool)` at
                test_kanban_offline.cpp:814 and :1114 (the `threw[i] = true`
                writes), 2 tests failed, 42 assertions failed
  vector<char>: 0 races, 0 `__bit_reference` frames, all 134 assertions pass,
                stable over 4 consecutive runs

A standalone 32-thread reduction of the same bookkeeping makes the mechanism
explicit: TSan flags the bit-reference assignment, and the run reports
`atomic failed=32  threw[] true-count=2` -- 30 lost updates.

Note the existing `Kanban / ThreadSanitizer` job could not have caught this: it
deliberately runs only the one Qt-free stress test, and this race is in a
different test, which is why it surfaced as an intermittent assertion failure
in the plain gcc leg instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es engine

Task 12's divergence test still described Phase 6's automation-rules engine as
something that "does not exist yet", with the cascade simulated by hand
because "today nothing does" it. Phase 6 has since landed: `evaluateRules()`
fires on `MoveTaskPosition` and guards on `morph::journal::isReplaying()`, and
Task 14 added "Replaying a move-to-Done journal entry does not re-fire its
rule" to cover that path with the real engine.

Rewrites the comment in present tense and states why both tests still earn
their place, rather than leaving a reader to wonder whether this one is
redundant now: Task 12 asserts a property of replay itself, against a cascade
owed to nothing but the test, so it fails whether or not any rule exists;
Task 14 asserts the engine's own replay guard. Break either and exactly one
of the two goes red.

No behavioural change -- comment only. The last stale artefact from the
rung-4 completion plan; every one of its 19 tasks has shipped deliverables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Yaraslaut
Yaraslaut merged commit cbfe1ee into master Aug 21, 2026
28 checks passed
@Yaraslaut
Yaraslaut deleted the ladder-kanban-impl branch August 21, 2026 14:45
Yaraslaut pushed a commit that referenced this pull request Aug 21, 2026
Resolves examples/ledger/README.md's open design questions in writing,
per LADDER.md's discipline rule, covering steps 1-7 of the build order
plus the step-8 sync-philosophy write-up (produced alongside, no new
model code required):

- The per-currency zero-sum invariant, defined precisely (legs sum to
  zero within each currency; foreign-amount pairs balance across, never
  entering the zero-sum check itself).
- Multi-currency: currency as an account property, not a per-transaction
  choice; exact Rational exchange rates; the corrected DecimalPlaces{0}
  fact for JPY/KRW (see the companion docs fix in the prior commit).
- Budget aggregation strategy (in-model summation, not SQL) and why the
  sanctioned-escape-tier doesn't apply here.
- Rules: reuses kanban's cascade-journaling decision verbatim (cited from
  its unmerged design spec), with rule-version pinning as the additional
  money-grade requirement layered on top, not a competing option.
- The one framework dependency this branch carries ahead of PR #121
  (causalParentId/isReplaying, cherry-picked framework-only).
- Undo as a compensating action, and why undoLast() is disqualified.
- The Rational overflow fuzz test and the pre-decode validation gap,
  each routed to a named finding rather than an app-side workaround.
- CSV/OFX import: bookmarks' op-id ledger pattern reused verbatim, plus
  a distinct content-hash dedup layer for cross-import duplicate
  detection.
- Reports: submit->poll shape, WAL-read-transaction snapshot semantics
  (the pre-cleared escape-tier case), and UTC-storage vs. local-month
  boundary handling.
- The sync-philosophy benchmark's three scenarios and the explicit
  "server arrival order, full stop" statement.
- Empty-principal refusal at the model, per LADDER.md's binding
  cross-rung convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Yaraslaut pushed a commit that referenced this pull request Aug 21, 2026
Resolves examples/ledger/README.md's open design questions in writing,
per LADDER.md's discipline rule, covering steps 1-7 of the build order
plus the step-8 sync-philosophy write-up (produced alongside, no new
model code required):

- The per-currency zero-sum invariant, defined precisely (legs sum to
  zero within each currency; foreign-amount pairs balance across, never
  entering the zero-sum check itself).
- Multi-currency: currency as an account property, not a per-transaction
  choice; exact Rational exchange rates; the corrected DecimalPlaces{0}
  fact for JPY/KRW (see the companion docs fix in the prior commit).
- Budget aggregation strategy (in-model summation, not SQL) and why the
  sanctioned-escape-tier doesn't apply here.
- Rules: reuses kanban's cascade-journaling decision verbatim (cited from
  its unmerged design spec), with rule-version pinning as the additional
  money-grade requirement layered on top, not a competing option.
- The one framework dependency this branch carries ahead of PR #121
  (causalParentId/isReplaying, cherry-picked framework-only).
- Undo as a compensating action, and why undoLast() is disqualified.
- The Rational overflow fuzz test and the pre-decode validation gap,
  each routed to a named finding rather than an app-side workaround.
- CSV/OFX import: bookmarks' op-id ledger pattern reused verbatim, plus
  a distinct content-hash dedup layer for cross-import duplicate
  detection.
- Reports: submit->poll shape, WAL-read-transaction snapshot semantics
  (the pre-cleared escape-tier case), and UTC-storage vs. local-month
  boundary handling.
- The sync-philosophy benchmark's three scenarios and the explicit
  "server arrival order, full stop" statement.
- Empty-principal refusal at the model, per LADDER.md's binding
  cross-rung convention.

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