Skip to content

fix(lists): viewing a list with rows threw — ListId was required but absent - #146

Merged
Adron merged 3 commits into
mainfrom
issue-144-listdatarow-crash
Sep 24, 2026
Merged

Adron merged 3 commits into
mainfrom
issue-144-listdatarow-crash

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #144

Severity: any list with at least one row failed to load

GET /api/lists/{id}/data rows do not include listId, but ListDataRow.ListId was declared required, so System.Text.Json threw before the rows reached the view:

JsonException: JSON deserialization for type 'InterlinedList.Models.ListDataRow'
               was missing required properties including: 'listId'.

Live row keys (2026-09-16) are exactly createdAt, createdByUser, id, lastEditedByUser, rowData, updatedAt, version. No listId.

This wasn't in the backlog — found while verifying a side-note from #17's service work.

Why it survived

Two reasons, and the second is the interesting one:

  1. The test account's only list has zero rows, so every manual pass over the Lists view exercised the empty path.
  2. The write path looks fine. POST /api/lists/{id}/data does return listId in its {message, data:{…}} envelope:
{"message":"Row created successfully",
 "data":{"id":"679fbb8b-…","listId":"0f061f4d-…","rowData":{"probe":"value"},"version":1,…}}

So verifying row-add against the create response — which is what the-gaps.md session 2 describes — confirms a field the read path omits. That asymmetry is recorded on the property so it doesn't get "tidied" back to required later.

Fix

ListId is nullable. The caller already knows which list it asked for.

Also models the fields the payload carries that the app was silently dropping:

Verification

Deserialized both real shapes in a net10.0 harness:

PARSED OK — rows=1
  ListId          = null (expected — read path omits it)
  Version         = 1
  RowNumber       = null
  CreatedByUser   = @messenger
  LastEditedByUser= null
  HasBeenEdited   = False
  DisplaySummary  = probe: value

create-shape row.ListId = L1 (populated when present)

grep -rn '\.ListId' shows no other consumer, so the nullability change has no call-site fallout. dotnet build green in Debug and Release.

Test-account hygiene

Two throwaway lists had to be created to obtain a row payload at all (the account had none). Both were deleted and the account confirmed back to its single pre-existing New list. The first attempt also taught me the create envelope is {message, data:{…}} rather than {list:{…}}, and that row creates want {data:…} not {rowData:…} — which is what the client already sends.

🤖 Generated with Claude Code

…absent

Any list holding at least one row failed to load. GET /api/lists/{id}/data rows
do not include `listId`, but ListDataRow.ListId was declared `required`, so
System.Text.Json threw before the rows ever reached the view:

  JsonException: JSON deserialization for type 'InterlinedList.Models.ListDataRow'
                 was missing required properties including: 'listId'.

Live row keys (captured 2026-09-16) are exactly:
  createdAt, createdByUser, id, lastEditedByUser, rowData, updatedAt, version

Found while verifying a side-note from #17's service work, not from the backlog.

Why it survived this long: the test account's only list has ZERO rows, so every
manual pass over the Lists view exercised the empty path. There is also an
asymmetry that hides it — POST /api/lists/{id}/data DOES return `listId` in its
{message, data:{...}} envelope, so a write-path verification looks fine. Only
the read path omits it.

Fix: ListId is now nullable, with the reasoning recorded on the property so it
does not get "tidied" back to required. The caller already knows which list it
asked for.

Also models the fields the payload carries that the app was dropping:
- version — monotonic per-row version, the hook for optimistic concurrency on
  row edits so two clients editing one row can be detected instead of silently
  last-writer-wins.
- rowNumber — server ordinal, null on a schema-less list.
- createdByUser / lastEditedByUser — who added and last changed the row, which
  is what makes a shared list legible (see #65).

Verified by deserializing both real shapes in a net10.0 harness — the read
payload now parses (ListId null, version 1, createdByUser @Messenger) and the
create-shape payload still populates ListId. No other code reads .ListId, so
the nullability change has no call-site fallout.

Test-account hygiene: two throwaway lists were created to obtain a row payload
at all (the account had none), then both deleted and the account confirmed back
to its single pre-existing list.

Closes #144

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit on this branch described ListDataRow.Version as "the hook
for optimistic concurrency on row updates, so two clients editing one row can be
detected rather than silently last-writer-wins."

That was an assumption, not a verified fact, and #18/#21's live probing
contradicted it. I re-checked it myself on a throwaway list:

  row at version 2, PUT {"data":{…},"version":1}
    -> 200, version becomes 3, the write lands

The server does not compare-and-swap on `version`. Row writes ARE
last-writer-wins and a concurrent edit is silently lost. It is a display/audit
value only. The comment now says so, and points at the app-settings store (#41)
as the contrast — that one does real CAS via `baseVersion` and returns
409 version_conflict.

Also records a second live finding from the same probe, which matters to #21:
PUT /api/lists/{id}/data/{rowId} REPLACES rowData rather than merging it. A row
holding {a,b} PUT with only {a} came back as {a} — `b` silently dropped. So a
row editor has to re-send every key it knows about, echoing untouched values.

No behaviour change; both are doc corrections on a model whose accuracy other
issues are now relying on.

Refs #144, #21

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron

Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Self-correction pushed — version is not optimistic concurrency

My original commit here described ListDataRow.Version as "the hook for optimistic concurrency on row updates, so two clients editing one row can be detected rather than silently last-writer-wins."

That was an assumption I stated as fact. #18/#21's live probing contradicted it, and I re-verified on a throwaway list:

row at version 2, PUT {"data":{…},"version":1}
  -> 200, version becomes 3, the write lands

The server does not compare-and-swap on version. Row writes are last-writer-wins, and a concurrent edit is silently lost. It's a display/audit value only. The doc comment now says so and points at the app-settings store (#41) as the genuine contrast — that one does real CAS via baseVersion and returns 409 version_conflict.

Second live finding recorded, relevant to #21

PUT /api/lists/{id}/data/{rowId} replaces rowData rather than merging it:

rowData {a:"edited", b:"two"}  →  PUT {"data":{"a":"only-a"}}  →  rowData {a:"only-a"}

b silently dropped. So a row editor must re-send every key it knows about, echoing untouched values — which is what #163 does.

Both are doc-only changes, no behaviour change. Worth correcting because other issues are now relying on this model's accuracy.

@Adron
Adron merged commit 4b44989 into main Sep 24, 2026
3 checks passed
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.

Lists: viewing any list that has rows throws — ListDataRow.ListId is required but the API doesn't send it

1 participant