Skip to content

fix(lists): the schema wire contract was a string client-side and an object server-side - #87

Merged
Adron merged 1 commit into
devfrom
fix/lists-detail-envelope
Sep 17, 2026
Merged

Adron merged 1 commit into
devfrom
fix/lists-detail-envelope

Conversation

@Adron

@Adron Adron commented Sep 15, 2026

Copy link
Copy Markdown
Member

Summary

Closes #75 and #85. One root cause, so one PR.

The client modelled a list's schema as a DSL string. The API models it as an object, and always has.

POST /api/lists  {"title":"…","schema":"Title:text, Year:number"}
→ 400 {"error":"Invalid schema: DSL must be an object","code":"bad_request"}

That is what CreateListRequest.schema: String? was sending. Creating a list with columns from macOS was a hard failure, not a degradation.

#75 as filed — the {data} envelope on GET /api/lists/{id} — turned out to be the least consequential member of the family: nothing shipped calls detail(listId:). The sweep it asked for found the rest.

The envelope map

Probed live against the .env test account. Transcripts in docs/spikes/list-schema-wire-shapes.md.

Route Live Client decoded Consequence
GET /api/lists/{id} {data:{…, properties[]}} bare ListDTO detail() never decoded — #75
GET /api/lists/{id}/schema {data:{name, description?, fields[]}} {schema:String} schema editor + row table dead
PUT /api/lists/{id}/schema body object; {message, data} {schema:String} schema save was a 400
POST /api/lists 201 {message, data} bare ListDTO create never decoded
PUT /api/lists/{id} {message, data} bare ListDTO update never decoded
GET /api/users/{u}/lists/{id} {list, ancestors} bare ListDTO public list page never decoded

Four envelope conventions on one resource family. OPTIONS proves the verb and says nothing about any of this.

⚠️ The PUT …/schema response contradicts the OpenAPI 200 example, which shows a bare {properties:[…]}. The capture wins.

Unlike detail(), ListsService.schema(of:) is live codeListRowsViewModel.swift:162 and SchemaEditorView.swift:268 both call it.

The key/label split is the deeper fix

Row data is keyed by the column's key; label is only the header text:

"rowData": {"title":"Dune","year":1965,"status":"done"}

SchemaField had one name doing both jobs. That was harmless only because the client could not create a schema at all — so every schema in the wild had key == label. The moment #85 is fixed, a column labelled "Publication Year" over a key of year renders every cell empty.

So key and label are separate, ListColumn { key, label } carries both through the table (header from label, subscript from key), and the DSL initialiser sets them equal — which is precisely what the DSL means.

ListDTO.schema was always nil

Documented as "present on detail and create responses". It is present on no captured payload. OwnedList.schemaDescription — shown in the list detail header and in the Markdown export — has therefore always been empty. Now derived from the real columns.

The destructive-change guard

PUT …/schema refuses to drop a column that still holds row data: 400 plus a propertiesWithData array, ?force=true confirms. The client knew nothing about it, so it presented as an unexplained failure.

It now surfaces as ListsError.schemaChangeWouldLoseData, and SchemaEditorViewModel gains pendingDestructiveSave + confirmDestructiveSave(). force is never set on a first attempt — there is no path to a forced write the server did not ask for, and a test asserts the call order is [false, true].

This unblocks #50

work-consolidation.md P2-G recorded the validation/help-text encoding as API-unconfirmed, which is what blocked item 1 of #50. It is confirmed — and it is not DSL syntax at all, just fields:

validationRules { min, max, minLength, maxLength, pattern, options }
helpText · placeholder · isRequired · isVisible · visibilityCondition
defaultValue · displayOrder

The row inspector renders helpText and placeholder here; the editor UI for the rules stays #50's scope.

visibilityCondition — conditional column visibility — was null on every capture, so it is decoded type-erased rather than guessed at. Modelling an unseen shape is exactly how the G21 and G25 defects happened.

Every fixture is captured

Three silent decode defects have now shipped in this client (G21 link metadata, G25 org members, and these two), and all of them had green tests written against invented fixtures. ListSchemaWireShapeTests uses verbatim response bodies, and pins both regressions directly:

func test_givenTheCapturedBody_whenDecodedAsABareList_thenItFails()
func test_givenTheSchemaBody_whenDecodedAsTheOldStringShape_thenItFails()

StubAPIClient now records the encoded request body, without which "sends a string where an object is required" was untestable at the domain layer.

Known limitation, flagged not worked around

The server's propertiesWithData column list cannot be surfaced: APIError.badRequest keeps only the decoded {error} string and the rest of the body is discarded in APIClient. ListSchemaConflictDTO models the full shape, so naming the columns becomes a one-line change once the kit keeps the body. The user still gets the server's own accurate sentence in the meantime.

Verification

  • xcodebuild build** BUILD SUCCEEDED **
  • xcodebuild test (App) → Executed 978 tests, with 0 failures · ** TEST SUCCEEDED ** (was 975)
  • swift test InterlinedDomainExecuted 1017 tests, with 0 failures (was 1012)
  • swift test InterlinedPersistenceExecuted 140 tests, with 0 failures
  • swift test InterlinedKitExecuted 483 tests, offline suites green; ⚠️ the 6 env-gated live ContractTests failed with "Too many attempts. Please try again later." — the account was rate-limited by this PR's own recon probing, not a regression.
  • Decision 0003 (anchored) → zero hits

Live account hygiene: one throwaway list was created, schema'd twice, given a row, made public for the public-route probe, and deleted. The account is back to its prior state.

🤖 Generated with Claude Code

https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9

The client modelled a list's schema as a DSL string. The API models it as an
object, and always has — `POST /api/lists` with a string schema answers a flat
`400 {"error":"Invalid schema: DSL must be an object"}`. Every route in the
family was therefore wrong, in both directions, and the tests that said otherwise
were asserting against fixtures they had invented.

What was broken, confirmed by live probe against the .env test account:

- `GET /api/lists/{id}` answers `{data}` and was decoded as a bare ListDTO, so
  `detail(listId:)` could never decode a real response. That is #75, and it was
  the least consequential member of the family — no shipped path called it.
- `GET /api/lists/{id}/schema` answers `{data:{name, description?, fields[]}}`
  and was decoded as `{schema: String}`. This one is not dead code:
  ListRowsViewModel and SchemaEditorView both call it, so the row table and the
  schema editor could not load a schema at all.
- `PUT /api/lists/{id}/schema` wants an object body and answers `{message, data}`
  — note that contradicts the OpenAPI 200 example, which shows a bare
  `{properties}`; the capture wins.
- `POST /api/lists` and `PUT /api/lists/{id}` answer `{message, data}`.
- `GET /api/users/{u}/lists/{id}` answers `{list, ancestors}` — a third envelope
  convention on the same resource — where the client decoded a bare ListDTO.

The deeper fix is the key/label split. Row data is keyed by the column's `key`;
`label` is only what the header renders. `SchemaField` had one `name` doing both
jobs, which was harmless only because the client could not create a schema at
all. The moment that was fixed, a column labelled "Publication Year" over a key
of `year` would have rendered every cell empty. `key` and `label` are now
separate, `ListColumn` carries both through the table, and the DSL initialiser
sets them equal — which is exactly what the DSL means.

`ListDTO.schema` was documented as "present on detail and create responses" and
is present on no captured payload, so `OwnedList.schemaDescription` has always
been empty wherever it was shown. It is now derived from the real columns.

Also lands the destructive-change guard the client knew nothing about: dropping a
column that still holds row data is refused with a 400 until `?force=true`. That
is a question, not a malfunction, so it surfaces as its own error and the editor
asks it. `force` is never set on a first attempt — there is no path to a forced
write the server did not ask for.

The per-column metadata this exposes — helpText, placeholder, isRequired,
isVisible, displayOrder, defaultValue and validationRules {min, max, minLength,
maxLength, pattern, options} — confirms work-consolidation.md P2-G, which is what
blocked item 1 of #50. The row inspector renders help text and placeholders now;
the editor UI for the rules is #50's.

Every fixture in this change is a captured response body, recorded in
docs/spikes/list-schema-wire-shapes.md, and the two regressions are pinned
directly: decoding the real payload as the old shape must throw. StubAPIClient
records the encoded request body so "sends a string where an object is required"
is testable at the domain layer at all.

One limitation is flagged rather than worked around: the server's
`propertiesWithData` column list cannot be surfaced, because APIError.badRequest
keeps only the decoded `{error}` string. ListSchemaConflictDTO models the full
shape so that becomes a one-line change once the kit keeps the body.

Refs #75, #85

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9
@Adron

Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Merge order — verified by actually integrating the stack

I built a throwaway branch off dev and merged the whole stack in order, so this is measured rather than predicted.

#99  fix/xcode27-document-ambiguity   clean
#87  fix/lists-detail-envelope         clean
#101 feat/lists-authoring-g50          clean   (stacked on #87)
#105 feat/saved-list-views-g40         CONFLICTS — 3 files
#106 refactor/apierror-response-body   clean

Result after resolving: ** BUILD SUCCEEDED **, App Executed 1035 tests, with 0 failures, Domain Executed 1039 tests, with 0 failures. The stack is sound; only the one merge needs hands on it.

The three conflicts, and what each actually is

1. ListMappers.swift — a true duplicate. #87 and #105 independently moved ListJSONValue.init(from: ListCellValue) out of ListsService.swift, for the same reason, with near-identical code.

Keep one copy, delete the other. Do not merge the hunks — concatenating them produces one function with two case .array bodies and it will not compile. Keep #105's doc comment (it names the second writer, which is the better explanation now one exists) and #87's body (explicit closures, because ListJSONValue.init(from:) is ambiguous against Decodable.init(from:)#87 hit that and fixed it).

2. ListsService.swift — genuinely additive. Two new ListsError cases, one from each branch (schemaChangeWouldLoseData, invalidViewName), plus their description arms. → Keep both, in both places.

3. ListRowsView.swift⚠️ a real semantic overlap, not additive. Both branches changed the same TableColumnForEach line:

They compose. Take #101's ListColumn access and #105's cellLineLimit:

TableColumnForEach(columns) { column in
    TableColumn(column.label) { (row: ListRow) in
        Text(row.fields[column.key]?.displayText ?? "")
            .lineLimit(cellLineLimit)
    }
}

Taking either side wholesale loses something real — #105's side reintroduces the key/label bug, #101's side drops the density control.

After the stack lands

#106 left its two consumers unwired to avoid a three-way stack. Once #87, #102 and #106 are on dev, each is one line:

  • ListsService.updateSchemasendCapturingFailure + details(as: ListSchemaConflictDTO.self), so the destructive-change confirmation names the columns
  • AppSettingsService → the same, for the 409's current document

ListSchemaConflictDTO already exists for the first.

@Adron
Adron merged commit 6c65eee into dev Sep 17, 2026
8 checks passed
@Adron
Adron deleted the fix/lists-detail-envelope branch September 17, 2026 03:23
Adron added a commit that referenced this pull request Sep 17, 2026
Two files conflicted. One was a true duplicate, and resolving it by keeping both
sides would have compiled and been wrong.

StubAPIClient.swift: this branch and #87 (now on dev) independently added request-
body recording to the test stub, for the same reason, within days of each other.
Near-identical code, different local names, different comments. The resolution is
one implementation, not two — but each side had a piece the other lacked, so it
is not simply "take one".

dev's encode also handles `.raw` bodies, which this branch's did not; strictly
more complete, so that is the one kept. This branch's `bodyJSON` accessor is what
its own AppSettingsServiceTests read (four call sites), and dev has no equivalent,
so that is kept too. Both motivating examples stay in the comment because both are
real shipped defects: a schema sent as a string where the server demands an object
(#85), and `{"name":…}` where the server demands `{"deviceName":…}` (#56). In both
cases every path-and-method assertion passed the whole time, which is the argument
for recording bodies at all.

SettingsRootView.swift auto-merged, and this time the auto-merge is genuinely
correct — verified rather than assumed, because the same file merged cleanly and
wrongly on #92. All eleven tabs carry a `.tag(SettingsTab...)`, including this
branch's rename of Devices to Applications, which kept its `.devices` tag. Under
`TabView(selection:)` an untagged tab cannot be selected at all, so the check is
worth making by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adron added a commit that referenced this pull request Sep 17, 2026
Three conflicts, and only one of them was mechanical.

ListMappers.swift was a true duplicate: this branch and #87 independently moved
`ListJSONValue.init(from: ListCellValue)` out of `ListsService.swift`, for the
same reason, within days of each other. One copy survives — concatenating them
produces a function with two `case .array` bodies that does not compile. The
comment keeps this branch's framing, which names the second writer that prompted
the move, and dev's body, which uses explicit closures because
`ListJSONValue.init(from:)` is ambiguous against `Decodable.init(from:)`.

ListsService.swift was additive on both sides — one new `ListsError` case from
each branch, plus their `description` arms. Both kept.

ListRowsView.swift was neither. Both branches changed the same
`TableColumnForEach` line: dev moved `columns` from `[String]` to `[ListColumn]`
so the header reads `label` and the cell subscript uses `key`, and this branch
added a density-driven `cellLineLimit`. They compose, and taking either side
alone loses something real — dropping `ListColumn` reintroduces the empty-cell
bug for any column whose key differs from its label, and dropping `cellLineLimit`
silently ignores the saved view's density.

Co-Authored-By: Claude Opus 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