Found while probing #75. More severe than #75 and the same root cause, so the two should be fixed together — but filed separately because #75 describes a dead method and this describes shipped, reachable, broken features.
The root cause
The client models a list's schema as a DSL string ("Title:text, Year:number"). The API models it as a structured object. Every route in the family is therefore wrong, in both directions.
Live evidence, captured 2026-09-15 against the .env test account (GET probes plus one create/update/delete cycle on a throwaway list).
1. Creating a list with a schema is a hard 400
What the macOS client sends today (CreateListRequest.schema: String?):
POST /api/lists {"title":"…","schema":"Title:text, Year:number"}
→ 400 {"error":"Invalid schema: DSL must be an object","code":"bad_request"}
The server wants schema as { name, description?, fields[] }:
POST /api/lists {"title":"probe","schema":{"name":"probe","fields":[
{"key":"title","type":"text","label":"Title","required":true}, … ]}}
→ 201 {"message":"List created successfully","data":{…,"properties":[…]}}
So ListsService.create(…) cannot create a schema-bearing list at all, and its response is {message, data} where Lists.create decodes a bare ListDTO — wrong twice over, same as POST /api/lists/{id}/data was before PR #24.
2. Reading a schema cannot decode
ListSchemaDTO is { schema: String } (ListDTO.swift:197). Live:
GET /api/lists/{id}/schema
{"data":{"name":"probe-object-schema","description":"recon probe","fields":[
{"key":"title","type":"text","label":"Title","displayOrder":0,"required":true,
"helpText":"What is it called?","placeholder":"e.g. Dune","visible":true,
"validation":{"pattern":"^[A-Za-z].*$","maxLength":80,"minLength":2}},
{"key":"status","type":"select","label":"Status","displayOrder":5,"required":false,
"visible":true,"defaultValue":"todo",
"validation":{"options":["todo","doing","done"]},"options":["todo","doing","done"]}]}}
Wrapped in data, and the inner object has name/description/fields[], not schema: String.
ListsService.schema(of:) is not dead code — it is called by App/Features/Lists/ListRowsViewModel.swift:162 and App/Features/Lists/SchemaEditorView.swift:268. The row table and the schema editor cannot load a schema.
3. Saving a schema sends the wrong body
UpdateListSchemaRequest sends {schema: String}; the server wants {schema: {name, description?, fields[]}} (or, alternatively, a properties array for a non-destructive per-column update). The live response is {message, data:{…, properties[]}} — note this contradicts the OpenAPI 200 example, which shows a bare {properties: […]}.
There is also a destructive-change guard the client knows nothing about: deleting a column that still holds row data returns 400 with a propertiesWithData[] array, and requires ?force=true to confirm the data loss.
4. rowData is keyed by key, not by the label
"rowData":{"due":"2026-01-02","year":1965,"title":"Dune","status":"done"}
SchemaField has a single name (ListSchema.swift:11) used as both the display label and the cell key. The server keeps them separate — propertyKey vs propertyName. This has not bitten only because macOS cannot create a schema today; it will bite the moment #75 and this are fixed, and it would present as every cell reading empty.
5. Same-family envelope mismatches
| Route |
Client decodes |
Live sends |
GET /api/lists/{id} |
bare ListDTO |
{"data":{…}} — this is #75 |
POST /api/lists |
bare ListDTO |
{"message","data"} |
PUT /api/lists/{id} |
bare ListDTO |
{"message","data"} |
PUT /api/lists/{id}/schema |
{schema:String} |
{"message","data"} |
This also unblocks #50
work-consolidation.md P2-G records the schema DSL's validation/help-text encoding as API-unconfirmed, which is why item 1 of #50 is blocked. It is now confirmed, and it is not DSL syntax at all — they are first-class fields:
validationRules: { min, max, minLength, maxLength, pattern, options }
helpText, placeholder, isRequired, isVisible, visibilityCondition,
defaultValue, displayOrder
visibilityCondition is a further field nothing in the client models — conditional column visibility, worth its own look.
Fix
One PR with #75, because they are one defect:
- Kit:
ListSchemaDSLDTO { name, description?, fields[] }, ListSchemaFieldDTO, ListValidationDTO, ListPropertyDTO; ListDTO.properties; every envelope above corrected; CreateListRequest.schema becomes the object.
- Domain:
SchemaField gains key separate from label, plus helpText, placeholder, defaultValue, isVisible, displayOrder, validation. The DSL string stays as an authoring convenience that compiles to the object; it stops being the wire format.
- Contract tests against the captured payloads above, not fabricated ones.
Acceptance
- A list with columns can be created from macOS.
- The schema editor and the row table load a real schema.
- A cell whose
key differs from its label renders its value.
- The destructive-change guard is surfaced rather than presenting as an opaque 400.
- BDD quartet across Kit / Domain / App.
Implementation plan (added 2026-09-15)
One PR with #75, because they are one defect. Sequenced so each layer is green before the next starts.
1. Kit — model the real shapes (ListSchemaDSLDTO.swift)
ListSchemaDSLDTO { name?, description?, fields[] } — one type for read and write; the server uses the same shape both ways.
ListSchemaFieldDTO { key, type, label?, displayOrder?, required?, visible?, helpText?, placeholder?, defaultValue?, validation?, options? }, with resolvedOptions reading whichever of the two option spellings the payload carried.
ListFieldValidationDTO { min, max, minLength, maxLength, pattern, options }, plus isEmpty so an untouched column never encodes an empty rules object — the server reads that as clear the rules.
ListPropertyDTO for the properties[] projection, with asSchemaField reconciling propertyKey/propertyName/validationRules into the DSL spelling so nothing above the kit has to know there are two.
- Envelopes:
ListResponse { message?, data, refreshStatus? }, ListSchemaResponse { data }, PublicListResponse { list, ancestors }.
ListSchemaConflictDTO for the destructive-change 400.
CreateListRequest.schema becomes the object. ListDTO gains properties; ListDTO.schema stays decodable but is documented as a field the server has never sent.
2. Domain — split key from label
SchemaField gains key (the row-data identity) separate from label (display), plus isRequired, isVisible, displayOrder, helpText, placeholder, defaultValue, validation.
name survives as a computed alias for label, and the DSL initialiser init(name:type:) sets key == label — which is what the DSL means. Existing call sites keep compiling and stay honest.
ListSchema.orderedFields honours displayOrder; field(key:) is the lookup that matters.
- An unrecognised
type token degrades to .text, never a decode failure — a new server column type must not take out the whole row table.
pattern is deliberately not evaluated client-side: pre-validating a server regex against a different engine would reject values the server accepts.
OwnedList.schemaDescription is derived from the real columns instead of the phantom wire field.
3. Domain — the service
create(…, schema: ListSchema?); an empty schema omits the key entirely rather than sending {fields: []}, which would ask for an explicitly column-less list.
updateSchema(of:schema:force:), with the 400 re-badged as ListsError.schemaChangeWouldLoseData so the UI asks the question instead of showing "Bad Request".
4. App — stop keying cells by the label
New ListColumn { key, label }. ListRowsViewModel.columns returns those; the table takes its header from label and its subscript from key, and drops isVisible == false columns. RowInspectorView does the same and finally renders the per-column helpText and placeholder the server has always stored.
SchemaEditorViewModel gains pendingDestructiveSave + confirmDestructiveSave(): force is never set on a first attempt, so there is no path to a forced write the server did not ask for.
5. Tests — captured payloads only
ListSchemaWireShapeTests uses verbatim response bodies, and pins both regressions directly: decoding the real body as the old shape must throw. The Domain fixtures are rebuilt from the same captures, and StubAPIClient now records the encoded request body so a test can assert the shape that goes on the wire — without which "sends a string instead of an object" was untestable at that layer.
Known limitation, filed separately
The propertiesWithData column list is not reachable: APIError.badRequest carries 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.
Out of scope
The editor UI for validation rules and help text — that is #50, which this unblocks by confirming P2-G. visibilityCondition was null on every captured payload, so it is decoded type-erased rather than guessed at.
Found while probing #75. More severe than #75 and the same root cause, so the two should be fixed together — but filed separately because #75 describes a dead method and this describes shipped, reachable, broken features.
The root cause
The client models a list's schema as a DSL string (
"Title:text, Year:number"). The API models it as a structured object. Every route in the family is therefore wrong, in both directions.Live evidence, captured 2026-09-15 against the
.envtest account (GETprobes plus one create/update/delete cycle on a throwaway list).1. Creating a list with a schema is a hard 400
What the macOS client sends today (
CreateListRequest.schema: String?):The server wants
schemaas{ name, description?, fields[] }:So
ListsService.create(…)cannot create a schema-bearing list at all, and its response is{message, data}whereLists.createdecodes a bareListDTO— wrong twice over, same asPOST /api/lists/{id}/datawas before PR #24.2. Reading a schema cannot decode
ListSchemaDTOis{ schema: String }(ListDTO.swift:197). Live:Wrapped in
data, and the inner object hasname/description/fields[], notschema: String.ListsService.schema(of:)is not dead code — it is called byApp/Features/Lists/ListRowsViewModel.swift:162andApp/Features/Lists/SchemaEditorView.swift:268. The row table and the schema editor cannot load a schema.3. Saving a schema sends the wrong body
UpdateListSchemaRequestsends{schema: String}; the server wants{schema: {name, description?, fields[]}}(or, alternatively, apropertiesarray for a non-destructive per-column update). The live response is{message, data:{…, properties[]}}— note this contradicts the OpenAPI 200 example, which shows a bare{properties: […]}.There is also a destructive-change guard the client knows nothing about: deleting a column that still holds row data returns 400 with a
propertiesWithData[]array, and requires?force=trueto confirm the data loss.4.
rowDatais keyed bykey, not by the labelSchemaFieldhas a singlename(ListSchema.swift:11) used as both the display label and the cell key. The server keeps them separate —propertyKeyvspropertyName. This has not bitten only because macOS cannot create a schema today; it will bite the moment #75 and this are fixed, and it would present as every cell reading empty.5. Same-family envelope mismatches
GET /api/lists/{id}ListDTO{"data":{…}}— this is #75POST /api/listsListDTO{"message","data"}PUT /api/lists/{id}ListDTO{"message","data"}PUT /api/lists/{id}/schema{schema:String}{"message","data"}This also unblocks #50
work-consolidation.mdP2-Grecords the schema DSL's validation/help-text encoding as API-unconfirmed, which is why item 1 of #50 is blocked. It is now confirmed, and it is not DSL syntax at all — they are first-class fields:visibilityConditionis a further field nothing in the client models — conditional column visibility, worth its own look.Fix
One PR with #75, because they are one defect:
ListSchemaDSLDTO { name, description?, fields[] },ListSchemaFieldDTO,ListValidationDTO,ListPropertyDTO;ListDTO.properties; every envelope above corrected;CreateListRequest.schemabecomes the object.SchemaFieldgainskeyseparate fromlabel, plushelpText,placeholder,defaultValue,isVisible,displayOrder,validation. The DSL string stays as an authoring convenience that compiles to the object; it stops being the wire format.Acceptance
keydiffers from itslabelrenders its value.Implementation plan (added 2026-09-15)
One PR with #75, because they are one defect. Sequenced so each layer is green before the next starts.
1. Kit — model the real shapes (
ListSchemaDSLDTO.swift)ListSchemaDSLDTO { name?, description?, fields[] }— one type for read and write; the server uses the same shape both ways.ListSchemaFieldDTO { key, type, label?, displayOrder?, required?, visible?, helpText?, placeholder?, defaultValue?, validation?, options? }, withresolvedOptionsreading whichever of the two option spellings the payload carried.ListFieldValidationDTO { min, max, minLength, maxLength, pattern, options }, plusisEmptyso an untouched column never encodes an empty rules object — the server reads that as clear the rules.ListPropertyDTOfor theproperties[]projection, withasSchemaFieldreconcilingpropertyKey/propertyName/validationRulesinto the DSL spelling so nothing above the kit has to know there are two.ListResponse { message?, data, refreshStatus? },ListSchemaResponse { data },PublicListResponse { list, ancestors }.ListSchemaConflictDTOfor the destructive-change400.CreateListRequest.schemabecomes the object.ListDTOgainsproperties;ListDTO.schemastays decodable but is documented as a field the server has never sent.2. Domain — split key from label
SchemaFieldgainskey(the row-data identity) separate fromlabel(display), plusisRequired,isVisible,displayOrder,helpText,placeholder,defaultValue,validation.namesurvives as a computed alias forlabel, and the DSL initialiserinit(name:type:)setskey == label— which is what the DSL means. Existing call sites keep compiling and stay honest.ListSchema.orderedFieldshonoursdisplayOrder;field(key:)is the lookup that matters.typetoken degrades to.text, never a decode failure — a new server column type must not take out the whole row table.patternis deliberately not evaluated client-side: pre-validating a server regex against a different engine would reject values the server accepts.OwnedList.schemaDescriptionis derived from the real columns instead of the phantom wire field.3. Domain — the service
create(…, schema: ListSchema?); an empty schema omits the key entirely rather than sending{fields: []}, which would ask for an explicitly column-less list.updateSchema(of:schema:force:), with the400re-badged asListsError.schemaChangeWouldLoseDataso the UI asks the question instead of showing "Bad Request".4. App — stop keying cells by the label
New
ListColumn { key, label }.ListRowsViewModel.columnsreturns those; the table takes its header fromlabeland its subscript fromkey, and dropsisVisible == falsecolumns.RowInspectorViewdoes the same and finally renders the per-columnhelpTextandplaceholderthe server has always stored.SchemaEditorViewModelgainspendingDestructiveSave+confirmDestructiveSave():forceis never set on a first attempt, so there is no path to a forced write the server did not ask for.5. Tests — captured payloads only
ListSchemaWireShapeTestsuses verbatim response bodies, and pins both regressions directly: decoding the real body as the old shape must throw. The Domain fixtures are rebuilt from the same captures, andStubAPIClientnow records the encoded request body so a test can assert the shape that goes on the wire — without which "sends a string instead of an object" was untestable at that layer.Known limitation, filed separately
The
propertiesWithDatacolumn list is not reachable:APIError.badRequestcarries only the decoded{error}string and the rest of the body is discarded inAPIClient.ListSchemaConflictDTOmodels the full shape, so naming the columns becomes a one-line change once the kit keeps the body.Out of scope
The editor UI for validation rules and help text — that is #50, which this unblocks by confirming
P2-G.visibilityConditionwasnullon every captured payload, so it is decoded type-erased rather than guessed at.