diff --git a/InterlinedList/Models/ListDataRow.cs b/InterlinedList/Models/ListDataRow.cs
index fd5b2f7..30134b5 100644
--- a/InterlinedList/Models/ListDataRow.cs
+++ b/InterlinedList/Models/ListDataRow.cs
@@ -2,15 +2,88 @@
namespace InterlinedList.Models;
+///
+/// One row of a list's data, from GET /api/lists/{id}/data.
+///
+///
+///
+/// Field set reconciled against the live read payload 2026-09-16. Row keys are
+/// exactly: id, rowData, version, createdAt, updatedAt, createdByUser,
+/// lastEditedByUser.
+///
+///
+/// is deliberately NOT required. The read
+/// endpoint does not send it — declaring it required made
+/// System.Text.Json throw
+/// "missing required properties including: 'listId'", so any list
+/// holding at least one row failed to load. Watch for the asymmetry that hid
+/// this: POST /api/lists/{id}/data does return listId in
+/// its {message, data:{…}} envelope; only the read path omits it. The
+/// caller already knows which list it asked for.
+///
+///
public sealed class ListDataRow
{
public required string Id { get; init; }
- public required string ListId { get; init; }
+
+ ///
+ /// Owning list. Absent on the read path — see the remarks. Populated when a
+ /// row comes back from a create/update response.
+ ///
+ public string? ListId { get; init; }
+
public required Dictionary RowData { get; init; }
+
+ ///
+ /// Monotonic row version, incremented on each edit.
+ ///
+ ///
+ ///
+ /// This is NOT optimistic concurrency, despite looking like it. An
+ /// earlier revision of this comment claimed it was; probing disproved that
+ /// (2026-09-16). Sending a deliberately stale version on
+ /// PUT /api/lists/{id}/data/{rowId} is accepted:
+ ///
+ ///
+ /// row at version 2, PUT with {"data":{…},"version":1}
+ /// -> 200, version becomes 3, the write lands
+ ///
+ ///
+ /// So the server does not compare-and-swap on it — row writes are
+ /// last-writer-wins and a concurrent edit is silently lost. Treat this as a
+ /// display/audit value only. (Contrast the app-settings store, which DOES
+ /// do real CAS via baseVersion and returns 409
+ /// version_conflict — see #41.)
+ ///
+ ///
+ public int Version { get; init; }
+
+ /// Server-assigned ordinal. Null on a schema-less list.
+ public int? RowNumber { get; init; }
+
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
- /// Read-only "key: value, key2: value2" preview — good enough since rows are freeform JSON with no schema.
+ /// Who added the row. Useful on a shared list — see the contributors work (#65).
+ public ApiUser? CreatedByUser { get; init; }
+
+ /// Who last edited it; null when never edited since creation.
+ public ApiUser? LastEditedByUser { get; init; }
+
+ ///
+ /// Read-only "key: value, key2: value2" preview. Adequate while rows are
+ /// freeform; the typed, schema-driven renderer is #21.
+ ///
+ ///
+ /// Note for anyone writing rows: PUT /api/lists/{id}/data/{rowId}
+ /// replaces rowData rather than merging it. Verified live —
+ /// a row holding {a,b} PUT with only {a} came back as
+ /// {a}, silently dropping b. So an editor must re-send every
+ /// key it knows about, echoing untouched values.
+ ///
public string DisplaySummary =>
string.Join(", ", RowData.Select(kv => $"{kv.Key}: {kv.Value}"));
+
+ /// Edited since creation, per .
+ public bool HasBeenEdited => LastEditedByUser is not null;
}