Repository navigation
bug(lists): GET /api/lists/{id} envelope mismatch makes ListsService.detail(listId:) undecodable #75
Description
Found during the PR #68 (G23) recon pass, deliberately left unfixed there because no G23 path exercised it. Split out during the 2026-09-13 tracker reconciliation.
The defect
GET /api/lists/{id} answers the {"data":{…}} envelope. Lists.get(id:) declares Request<ListDTO> and decodes a bare ListDTO.
Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/ListsEndpoint.swift:46Packages/InterlinedDomain/Sources/InterlinedDomain/Services/ListsService.swift:359
So ListsService.detail(listId:) cannot decode a live response at all — it fails on every real call.
Why nothing has broken yet
No shipped path calls it. The shared-with-me rows in G23 come from the watching payload itself, and the owned-lists surfaces use the collection routes. The method is correct-looking, tested against a fabricated bare-ListDTO fixture, and dead.
That combination is the dangerous part: the next feature that reaches for "fetch one list by id" will hit a decode failure that looks like a server problem, and the existing green test will argue it cannot be the client.
This is the same family as two shipped defects
Both were silent decode mismatches whose tests passed against invented fixtures:
- G21 link metadata — the server nests preview fields under
metadata; the flat all-optional decode succeeded to all-nil, so every timeline link rendered host-only. - G25 org members —
OrganizationMemberDTOrequireduserIdwhere live rows key byid, so the roster never rendered.
Fix
Point Lists.get(id:) at the envelope, after probing the live shape read-only rather than assuming {"data":…} — this API has documented shape drift, and single-resource routes elsewhere answer {message?, <resource>}. Replace the fabricated fixture with the captured payload. Ship the BDD quartet.
Acceptance
- A live
GET /api/lists/{id}response decodes. - The test fixture is a captured payload, not a hand-written one.
- Any other
Request<T>inListsEndpointdecoding a bare DTO where the server sends an envelope is caught in the same sweep.
Implementation plan (added 2026-09-15) — fixed together with #85
The probe this issue asked for was done, and it found that #75 is the least consequential member of a family. The client models a list's schema as a DSL string; the API models it as an object. Every route in the family is wrong in both directions. The full evidence is in #85 and in docs/spikes/list-schema-wire-shapes.md.
GET /api/lists/{id} is confirmed exactly as described here:
GET /api/lists/33de2874-…
200 {"data":{"id":"33de2874-…","title":"New list",…,"properties":[]}}
So this is fixed in the same PR as #85 rather than on its own — splitting them would mean touching ListsEndpoint, ListsService and the fixtures twice for one root cause.
Scope carried by this issue
Lists.get(id:)→ListResponse({message?, data}), andListsService.detail(listId:)unwraps it.- The same sweep across every
Request<T>inListsEndpointthat this issue asked for. It found four more:create,update,refresh(all{message, data}) andpublicList({list, ancestors}— a third envelope convention on the same resource). - The fabricated bare-
ListDTOfixture is replaced by the captured payload, and a test pins the regression directly: decoding the real body as a bareListDTOmust throw.
Acceptance
- ✅ A live
GET /api/lists/{id}response decodes. - ✅ The fixture is captured, not hand-written.
- ✅ Every other
Request<T>inListsEndpointdecoding a bare DTO against an enveloped route is caught in the same sweep.
Activity
Adron commentedon Sep 16, 2026
Implemented in PR #87, together with #85 — they are one root cause. Work continues on the PR.
GET /api/lists/{id}confirmed live to answer{"data":{…}}, exactly as described here. The sweep this issue asked for ("any otherRequest<T>inListsEndpointdecoding a bare DTO where the server sends an envelope") found four more:create,update,refresh(all{message, data}) andpublicList({list, ancestors}— a third envelope convention on the same resource).The fabricated bare-
ListDTOfixture is replaced by the captured payload, and the regression is pinned directly: decoding the real body as a bareListDTOmust throw.