Skip to content

ladder: make the zero-sentinel id constraint explicit and enforced - #260

Open
Yaraslaut wants to merge 2 commits into
masterfrom
fix/215-optional-backed-ids
Open

ladder: make the zero-sentinel id constraint explicit and enforced#260
Yaraslaut wants to merge 2 commits into
masterfrom
fix/215-optional-backed-ids

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Closes #215.

The defect

polls::OptionId, polls::PollEventId and kanban::BoardEventId use value == 0
as their "not entered" state, so an id of 0 is unrepresentable — construct
one and it reports hasValue() == false and behaves as absent everywhere
downstream.

Nothing is broken today: all three come from SQLite row ids, which start at 1.
It matters because the constraint was implicit, and the failure mode if a 0
ever does arrive — a seeded row, a migrated dataset, an externally supplied key,
a sequence reset — is that a real record reads as "no record", one layer below
the QML boundary where no conversion helper can restore the distinction.

Why option 2, not option 1

The issue offers three options and notes that (1), converting to the
optional-backed shape, "is the only one that removes the trap rather than
documenting it". I took (2), documented-and-enforced, for two reasons the
issue does not account for:

  • kanban already documents the zero-sentinel as deliberate. Its types.hpp
    says the shape is chosen because the id "is always looked up already-assigned",
    citing design spec §7 — and PollEventId{} is the natural spelling of "no
    cursor yet, replay from the beginning" for GetEventsSince. Converting would
    reverse a recorded decision.
  • Option 1 changes the wire format. The optional-backed shape serialises the
    empty state as null rather than 0, for two shipped rungs.

If you'd rather have uniformity with BookmarkId across the ladder, option 1 is
still open — but it's a decision about a documented choice, not a cleanup, which
is why I didn't take it unilaterally.

Enforcement, not just prose

A checked factory, fromRowId(), which every conversion from a stored row id now
goes through — six sites in poll_model.cpp and board_model.cpp. It rejects
0 loudly instead of letting it collapse into the empty state.

One site deliberately does not use it. decodeVotes() in
poll_qml_bridges.cpp builds an OptionId from QML-supplied input, where
toLongLong() yields 0 for a missing or non-numeric field — precisely the "not
entered" state the action's validate() exists to reject, as a clean
ValidationError rather than an exception. That distinction now carries a
comment, since it's exactly what a future reader would trip on.

A documentation defect underneath

polls/core/types.hpp claimed these two ids follow BookmarkId's pattern.
They don't — BookmarkId is optional-backed, which is the whole point of this
issue. Corrected, with the real constraint and its rationale recorded on all
three types.

Verification

check result
polls + kanban ladder suites 198 / 198 passed
new fromRowId cases rejection and control cases — a factory that rejected everything would pass the rejection test alone
clang-tidy-diff (clang 22, as CI pins) clean
-Wdocumentation -Werror on both changed headers clean

The last two were run locally because #258 showed CI's clang-tidy-diff and the
WASM job's -Weverything catch things an ordinary local build does not.

polls::OptionId, polls::PollEventId and kanban::BoardEventId use `value == 0` as
their "not entered" state, so an id of 0 is unrepresentable: construct one and it
reports hasValue() == false and behaves as absent everywhere downstream. Nothing
is broken today -- all three come from SQLite row ids, which start at 1 -- but
the constraint was implicit, and the failure mode if a 0 ever did arrive (a
seeded row, a migrated dataset, an externally supplied key, a sequence reset) is
that a real record reads as "no record".

#215 offers three options. This takes (2), documented-and-enforced, not (1),
convert-to-optional-backed, for two reasons the issue does not account for:

- kanban's types.hpp already documents the zero-sentinel shape as a deliberate
  choice ("it is always looked up already-assigned", citing design spec §7), and
  PollEventId{} is the natural spelling of "no cursor yet, replay from the
  beginning" for GetEventsSince. Converting would reverse a recorded decision.
- The optional-backed shape changes the empty state's wire form from 0 to null
  for two shipped rungs.

Enforcement is a checked factory, fromRowId(), which every conversion from a
stored row id now goes through; it rejects 0 loudly instead of letting it
collapse into the empty state one layer below the QML surface, where no
conversion helper can restore the distinction. Six conversion sites in
poll_model.cpp and board_model.cpp adopt it.

One site deliberately does not: decodeVotes() in poll_qml_bridges.cpp builds an
OptionId from *QML-supplied* input, where toLongLong() yields 0 for a missing or
non-numeric field -- which is precisely the "not entered" state the action's
validate() exists to reject, as a clean ValidationError rather than an
exception. That distinction now has a comment.

Also corrects polls/core/types.hpp's file comment, which claimed these two
follow BookmarkId's pattern. They do not -- BookmarkId is optional-backed, and
that mismatch is the documentation defect underneath this issue.

Verified: polls + kanban ladder suites 198/198, including the new
fromRowId cases and their control cases (a factory that rejected everything
would pass the rejection test alone). clang-tidy-diff and -Wdocumentation clean.

Closes #215
The original run was cancelled by the supersede-obsolete-runs concurrency rule
(#257) shortly after it merged, and re-running the cancelled workflows produced
attempts that were themselves cancelled within minutes. An empty commit gives
the PR a fresh head so its checks run from a clean slate.

No content change.
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.

Three ladder id types use 0 as their empty sentinel, so they cannot represent an id of 0

1 participant