Skip to content

offline+net+docs: seven tickets, five of which turned on a premise measurement contradicted (fixes #695, fixes #696, fixes #640, refs #658, refs #566, refs #725, refs #534) - #741

Merged
Yaraslaut merged 3 commits into
masterfrom
worktree-agent-aba0bde7c3ea2af2c
Sep 22, 2026
Merged

Yaraslaut merged 3 commits into
masterfrom
worktree-agent-aba0bde7c3ea2af2c

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Seven tickets, one commit per ticket where there was code to write. Three shipped; four were rescope/unverified tickets whose deliverable was the answer, and the answers are on the issues.

The through-line: five of the seven turned on a premise that measurement contradicted. Two Lightweight constraints three plan documents still assert are retired upstream. A GCC diagnostic reported at -O0 only reproduces at -O2. A thread-safety hazard measured its way to invalid. And morph#658's "one server process, sequential corpus, therefore no second client" is false — the ledger server runs a four-thread pool and a 1 Hz background sweeper.


Shipped

fixes #695 — one page of Lightweight constraints, and two of the three turn out to be stale

docs/LIGHTWEIGHT-CONSTRAINTS.md, linked from examples/IMPLEMENTATION.md where a rung author looks. Every entry carries the pinned revision it was verified at, the file and line in Lightweight, the in-tree workaround and the condition that retires it.

Checking the three seed constraints at the pin (bbb972a7, g++ 16.2.1, -fsyntax-only, non-reflection) is what the page is for, and it earned itself immediately — only one of the three survives:

constraint, as the plan archive records it at the pin
Where() cannot bind a binary value confirmed, now as a compile diagnostic rather than by reading the variant
fluent Query/Update refuse HasMany-bearing records false in both halves
HasMany resolves a child FK by ordinal member index retired upstream
  • DataMapper::Update on a HasMany-bearing record compiles. So does Query<T>().Where(...).All(). What is true is narrower and not about HasMany at all — Query<Record>() has no Update() for any record, which the control probe on a HasMany-free record shows by failing identically.
  • Record.hpp:242-255 now states that relations resolve by relationship type, never by member position, with three static_asserts turning every failure mode into a named compile error. Verified with the child's BelongsTo declared last, which positional resolution would get wrong.

A fourth entry, found while checking the others and already worked around in-tree: no CreateIndex overload takes a predicate, so a partial unique index has no spelling in the migration DSL.

Upstream issue filed for the live one, so the entry has a retirement condition someone tracks: LASTRADA-Software/Lightweight#618.

fixes #696 — the attempt count gets its own type

The triage's recount was right: five definitions, one production call site. morph::offline::Attempts is implicitly constructible from a narrow integer and deliberately not from a 64-bit one, which is what a QueueItem::id is.

The transposed call, as required:

$ g++ -std=c++23 -fsyntax-only -DTRANSPOSED=1 ...
error: cannot convert 'const uint64_t' {aka 'const long unsigned int'} to 'morph::offline::Attempts'
    8 |     queue.setAttempts(attempts, itemId);  // must not compile
      |                                 ^~~~~~
include/morph/offline/offline_queue.hpp:404:48: note: initializing argument 2 of
  'virtual void morph::offline::InMemoryOfflineQueue::setAttempts(uint64_t, morph::offline::Attempts)'

$ g++ -std=c++23 -fsyntax-only -DTRANSPOSED=0 ...
exit=0

Two static_asserts hold the property in place, and they were mutation tested — weakening the constructor's constraint from sizeof(Count) < to <= sizeof(uint64_t) makes both fire:

offline_queue.hpp:68: error: static assertion failed: Attempts must not be constructible from a queue item id...
offline_queue.hpp:319: error: static assertion failed: setAttempts's parameters must not be transposable...

They are a pair on purpose: an alias for uint32_t fails the first, an explicit constructor fails the second. Neither alone would mean anything.

The NOLINT in examples/bank/src/offline/lightweight_offline_queue.cpp is deleted. No test call site in the tree changed — the implicit-from-narrow constructor is what buys that, which is also why this stayed inside the sizing the triage established.

QueueItem::attempts stays a plain uint32_t: a struct field reached by name has no adjacent same-typed field to transpose with. The hazard was in the parameter list, and that is where the type went.

One line in another lane's file. tests/test_sync_worker.cpp:406 is a recording test double whose override signature must follow the interface. That override — and only that override — is touched.

fixes #640 — gai_strerror measured, not assumed

Neither man page is installed here either, so the property was measured directly rather than read off a table, which is stronger: it tests the hazard instead of the documentation of it.

glibc 2.44, gcc -O0:

EAI_AGAIN ptr=0x7febd9bb62f2 "Temporary failure in name resolution"
EAI_FAIL  ptr=0x7febd9bb632e "Non-recoverable failure in name resolution"
EAI_AGAIN ptr=0x7febd9bb62f2   (second call, same code)
distinct pointers for distinct codes: YES     first string intact after second call: YES
dladdr -> /usr/lib/libc.so.6, anonymous (a literal in a data section)
unknown code 12345 -> "Unknown error"   (a constant, not a formatted buffer)

7fa71e99f000-7fa71ea15000 r--p 0019f000 00:1c 15085232  /usr/lib/libc.so.6

r--p is the load-bearing line: std::strerror's defect is a shared mutable buffer, and a read-only mapping cannot be one. musl/emscripten is a static const char msgs[] table by inspection. Winsock's documented-unsafe gai_strerrorA is never compiled — TcpSocket is POSIX-only and CI's Windows leg cannot reach the file.

So the call stays, for two reasons the issue asked to keep apart: rc is an EAI_* code and not an errno, and separately it does not have the defect. Recorded at the call site and in docs/spec/security.md, whose bullet previously gave only the first reason. Both records state what was not established: no other libc, and POSIX's own text still unread.


Answered, not shipped — refs, not fixes

refs #658 — the static question is no, and the premise behind it is false

Enumerated every DataMapper construction (eleven in ledger_model.cpp, five in budget_model.cpp, two in rule_model.cpp — all at the top of a public execute()) and every SqlTransaction (:880, :1537, :1601). No ledger handler opens a second mapper inside another. SetCategory, the handler that failed, is one mapper and one transaction; setCategoryImpl takes DataMapper&; the rule cascade passes the same mapper deliberately.

By the triage's own terms that returns the issue to unexplained. Except the fourth fact in "one server process, sequential corpus ⇒ no second client" is wrong:

App::App(..., std::size_t workers, ...) : _pool{workers}        // app.cpp:137, workers = 4
_reportTimer.start(runInterval);                                 // app.cpp:199, default 1s
::Lightweight::SqlStatement stmt;                                // app.cpp:227 — its OWN connection
handler->execute(RunReportJob{...})                              // app.cpp:269 — onto a pool thread
WalSnapshotGuard snapshot{mapper.Connection()};                  // ledger_model.cpp:1460 — BEGIN DEFERRED

A four-thread worker pool and a 1 Hz sweeper that opens a second connection and dispatches a job holding a read transaction across a whole aggregation. Plus: the effective busy timeout is 60000 ms, not 5000 — Lightweight's PostConnect() issues PRAGMA busy_timeout = 60000 after connect and wins over the connection string's Timeout=. And SQLite returns SQLITE_BUSY without invoking the busy handler when a read lock inside an open transaction is promoted while another connection holds RESERVED — which is exactly SetCategory's shape (four reads inside the transaction, then mapper.Update). Inferred, not measured; the full write-up and its falsification test are on the issue.

refs #566 — same family, different mechanism; they do not share a fix

Kanban does nest — execute(MoveTaskPosition) holds three pooled connections at once (:962, :1199, :946) and opens a second write transaction on the third. But the enclosing transaction is committed before the nesting starts, so it is not #658's self-deadlock and #658's finding does not cover it. Said so on both issues, with a table.

New, small, and concrete: buildState at :1175 runs on connection A while transaction is still in scope, so autocommit is off and that post-commit read opens a second transaction held until end of function. That is the statement most likely to have thrown, and scoping the transaction to end at its Commit() is a one-line reduction of the window. Recommended on the issue, not shipped — the root defect is post-commit work throwing out of a committed call, and per AGENTS.md that is the issue's change, not this batch's.

refs #725 — false positive, proven

crm/tests/test_pipeline.cpp     -O0 : 0 diagnostics    -O1 : 0    -O2 : 2    -O3 : 2
crm/tests/test_offline_sync.cpp -O0 : 0 diagnostics    -O1 : 0    -O2 : 2    -O3 : 2

-O0/-O1 clear it. But optimisation-dependence is only suggestive, so the sizes close it:

sizeof(ModelHolder<crm::AccountModel>)     = 112     <- what GCC bounds against
sizeof(ModelHolder<crm::OpportunityModel>) = 136     <- what the store belongs to

"Array subscript 14" is byte offset 112 — in bounds of 136, out of bounds only of 112. GCC speculatively devirtualized IModelHolder::attachActionLog to ModelHolder<OpportunityModel>'s override inside ModelFactory::create<AccountModel>() — a dispatch that cannot happen — then applied the AccountModel holder's size to an offset from the OpportunityModel holder's layout. crm is the only rung with SelfJournal, which is exactly the 24-byte difference, answering the issue's third "not verified" item.

No suppression added, per the batch's scope. Also corrects the issue's own repro: morph sets no default CMAKE_BUILD_TYPE, so its unoptimised configure should not have reproduced this, and a -DCMAKE_BUILD_TYPE=Debug build of ladder_crm_tests succeeds here on the same GCC.

refs #534 — one of the three is a bug, one is second-order, one would break the product

Rescoped before building, as asked:

  • handshakeTimeout — build it. The only one that is a liveness defect rather than a policy: one byte parks a thread and an fd forever, nothing else in the class bounds it, close() cannot reap it, and no legitimate client behaves that way, so a timeout on it has no false-positive cost.
  • maxConnections — worth building, second. Real, and parity with QtWebSocketServerConfig — but with a handshake timeout in place it defends a DoS posture, not a correctness one, and should be described that way.
  • idleTimeout — reject. A desktop GUI client left open is idle by design. SocketServer has no ping/pong, so it cannot tell "user at lunch" from "peer died", and the timeout would disconnect working clients on a timer. It becomes defensible only after a keepalive exists — at which point it is a different feature with a different name.
  • sendTimeout — belongs with the first. Per the manager note, it is what makes net: reject illegal WebSocket frames, and bound the handshake read #558's already-merged shutdownBoth() reachable, and it carries this ticket's added regression-test clause.

Nothing built — said so plainly rather than half-landing two of four and leaving the third looking decided.


Filed in passing

No triage: labels applied to any of them.


Gates

All run after the final edit, exit codes captured directly (no | tail).

gate result
check_allowlist_citations.py 0 — 37 citations across 3 allowlists resolve
check_mutation_survivors.py 0 — 15 structured citations resolve
both --self-tests 0, 0
check_nolint_directives.sh 0
check_rung_filters.sh 0
check_spec_sync.sh 0 — 10 sub-domains classified, fed the real changed-path list
check_spec_citations.sh 0
whole-tree clang-format --dry-run -Werror (22.1.8) 0
clang-tidy-diff over the real diff 0
Doxygen --target doc 0
full GCC 16 build (ladder + bank + Qt + tests) 0
ctest -j8 -L "bank|ladder" 1059/1059
ctest -j8 (everything) 2735/2735

clang-tidy-diff returning clean on a small diff is a check that could pass by reading nothing, so it was mutated: injecting int q = 0; into a changed line makes it exit 1 with misc-const-correctness and readability-identifier-length. It is reading the diff.

check_spec_sync.sh initially failed — include/morph/net/** changed with no matching spec change. Resolved by extending the existing gai_strerror bullet in docs/spec/security.md, which is where morph#625's sibling decision already lives, rather than by reaching for the no docs update label.

Not touched

scripts/mutation_survivors.json and scripts/branch_partial_allowlist.json — both still resolve cleanly against this branch, so there is no stale citation to report. include/morph/util/rational.hpp — untouched, per the note about PR #561.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW

Yaraslaut and others added 3 commits September 22, 2026 21:57
…out to be stale (fixes #695)

Three plan documents each recorded the same Lightweight limits, because each
rung rediscovered them and wrote them into its own plan, where the next rung
could not find them. This collects them into one page under docs/, where a
rung author looks, and gives every entry what a plan entry does not have: the
pinned revision it was verified at, the file and line in Lightweight that
decides it, the workaround actually used in this tree, and the condition that
retires it.

Checking them at the pin (bbb972a7, g++ 16.2.1, -fsyntax-only, non-reflection)
is what the page is for, and it immediately earned itself. Of the three
constraints the issue names, only one survives:

- Where() cannot bind a binary value: CONFIRMED, and now as a compile
  diagnostic rather than by reading the variant. SqlVariant::InnerType has
  zero binary alternatives; the identical call with a std::string compiles, so
  the refusal is about the value type. Filed upstream as
  LASTRADA-Software/Lightweight#618 so the entry has a retirement condition
  somebody tracks.

- "Fluent Query/Update refuse HasMany-bearing records": FALSE in both halves.
  DataMapper::Update on a HasMany-bearing record compiles; so does
  Query<T>().Where(...).All(). What is true is narrower and is not about
  HasMany at all -- Query<Record>() has no Update() for ANY record, which the
  control probe on a HasMany-free record shows by failing identically.

- "HasMany resolves a child FK by ordinal member index": RETIRED upstream.
  Record.hpp:242-255 now says in its own words that resolution is by
  relationship type, never by member position, and InverseBelongsToResolver
  carries three static_asserts turning every failure mode into a named
  compile error. Verified with the child's BelongsTo declared last, which
  positional resolution would get wrong.

A fourth entry, found while checking the others and already worked around
in-tree: the migration DSL has no predicate on any CreateIndex overload, so a
partial unique index has no spelling.

The page says outright that an entry is only as good as its last
verification, because entries 2 and 3 are what that rule was written from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…tempts does not compile (fixes #696)

setAttempts(uint64_t itemId, uint32_t attempts) was two adjacent, mutually
convertible integers. bugprone-easily-swappable-parameters said so at every
implementation, which meant every implementor hand-wrote a suppression for a
hazard they did not choose -- the framework exporting its lint bill to its
consumers -- and the suppression left the hazard exactly where it was.

The hazard is silent in both directions, which is what makes it worth a type
rather than a NOLINT: setAttempts on an unknown id is a documented no-op, so a
transposed call writes an id into a count, matches nothing, returns normally
and throws nothing. The real item's count never advances, and the defect
surfaces much later as a retry budget that never exhausts.

morph::offline::Attempts is implicitly constructible from a narrow integer and
deliberately NOT from a 64-bit one, which is what a QueueItem::id is. That is
the whole mechanism: setAttempts(attempts, itemId) stops compiling, while
every existing call site -- setAttempts(id, 3), setAttempts(id, counter) --
reads exactly as before. No test call site in the tree changed. A genuinely
64-bit count stays expressible with the narrowing visible at the call site.

Proven rather than asserted, both directions:

  $ g++ -fsyntax-only ... -DTRANSPOSED=1
  error: cannot convert 'const uint64_t' to 'morph::offline::Attempts'
      8 |     queue.setAttempts(attempts, itemId);
  $ g++ -fsyntax-only ... -DTRANSPOSED=0
  exit=0

and the two static_asserts that hold the property in place were mutation
tested -- weakening the constructor's constraint to sizeof(Count) <=
sizeof(uint64_t) makes both of them fire, so neither is vacuous. They are
written as a pair on purpose: an alias for uint32_t fails the first, an
explicit constructor fails the second.

QueueItem::attempts stays a plain uint32_t. It is a struct field reached by
name with no adjacent same-typed field to transpose it with; the hazard was in
the parameter list and that is where the type went.

Deletes the hand-written NOLINT in examples/bank, which is the second data
point the issue cites and the reason the tax was recurring rather than
hypothetical.

tests/test_sync_worker.cpp:406 is a recording test double whose override
signature has to follow the interface; that one override is the only line
touched in that file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…ablished (fixes #640)

The triage asked for one of two readings -- glibc's ATTRIBUTES table or the
POSIX text -- and neither man page is installed on this machine either. So the
property was measured directly, which is stronger than reading a table because
it tests the hazard rather than the documentation of it.

glibc 2.44, gcc -O0:

  EAI_AGAIN ptr=0x7febd9bb62f2 "Temporary failure in name resolution"
  EAI_FAIL  ptr=0x7febd9bb632e "Non-recoverable failure in name resolution"
  EAI_AGAIN ptr=0x7febd9bb62f2 (second call, same code)
  distinct pointers for distinct codes: YES
  first string intact after second call: YES
  dladdr -> /usr/lib/libc.so.6, anonymous (a literal in a data section)
  unknown code 12345 -> "Unknown error"   (a constant, not a formatted buffer)

  7fa71e99f000-7fa71ea15000 r--p 0019f000 00:1c 15085232  /usr/lib/libc.so.6

The r--p is the load-bearing part. std::strerror's defect, which morph#625
removed, is a shared MUTABLE buffer a second caller overwrites; a read-only
mapping cannot be one. Distinct pointers per code rule out a rotating slot,
and even the unrecognised-code path returns a constant, so there is no
per-call buffer on any path.

musl/emscripten, which the WASM build links, is a static const char msgs[]
table by inspection. Winsock's documented-unsafe gai_strerrorA is never
compiled: TcpSocket is POSIX-only, and CI's Windows leg cannot reach this
file.

So the call stays, and now for two reasons the issue asked to be kept apart:
rc is an EAI_* code and not an errno, so morph#641's errnoMessage() would
render a confidently wrong string -- and separately, it does not have the
defect that would make replacing it worth the effort.

Recorded at the call site and in docs/spec/security.md, whose existing bullet
said gai_strerror was "deliberately untouched" only for the first reason.
Both records state what was NOT established: no other libc was checked and
POSIX's own text is still unread, so this is a measurement on the
configurations morph builds, not a portability guarantee.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
@codecov

codecov Bot commented Sep 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@Yaraslaut
Yaraslaut merged commit 0890f40 into master Sep 22, 2026
56 checks passed
@Yaraslaut
Yaraslaut deleted the worktree-agent-aba0bde7c3ea2af2c branch September 22, 2026 22:41
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