interlinedlist-macos-native
49 rows · Updated
| Issue # | Title | Body | State | Labels | Assignees | Link | Created | Updated |
|---|---|---|---|---|---|---|---|---|
| 109 | docs: api-coverage.md omits shipped routes and needs a re-score pass | Split out of the 2026-09-17 doc sync (PR #108), which corrected `work-consolidation.md` but deliberately left this file alone. ## The gap `docs/api-coverage.md` **omits rows entirely** for routes the client now builds and ships: | Route | Shipped in | |---|---| | `DELETE /api/user/identities` | PR #93 (#47) | | `POST /api/user/identities/verify` | PR #93 (#47) | | `GET /api/auth/github/status` | PR #93 (#47) | | `GET /api/github/orgs` | PR #93 (#47) | | `GET`/`PUT`/`DELETE /api/user/app-settings/{appKey}` | PRs #25/#30, corrected #102 (#56) | | `GET /api/user/app-settings/{appKey}/bootstrap` | same | | `GET`/`POST /api/user/app-settings/{appKey}/devices` | same | | `PATCH`/`DELETE …/devices/{deviceId}` | same | | `GET`/`PUT …/devices/{deviceId}/settings` | same, and PR #107 (#104) | | `GET`/`POST`/`PUT`/`DELETE /api/lists/{id}/views` + fork | PR #105 (#81) | Some of these have been shipped for weeks. A coverage matrix that does not list a route the client calls is worse than one that lists it as unbuilt — it reads as "not in the API" rather than "not scored". ## Why it was not fixed in passing The file maintains **arithmetic invariants**: per-section subtotals and a grand total, both stated in prose and already corrected once for an off-by-thirty slip (footnote 14, and the 2026-09-05 entry). Adding rows without recomputing those makes the document internally inconsistent, which is a worse failure than the omission. It also has a **maintenance rule** governing when `Implemented` / `Tested` may flip — a tested App-layer view model has to drive the row end-to-end. Several of the routes above now qualify, and several do not, and deciding that per row is the actual work. ## Also worth re-scoring while in there The 2026-09-14 pass **explicitly did not re-score** `Implemented`/`Tested` — it only derived the `Auth` column from the live spec. Since then PRs #86–#107 shipped a great deal, so the scored state is stale in both directions: rows marked ☐ that now have end-to-end tests, and rows whose *method or path* changed under them (the list-schema family in #85, the notification routes in #95). ## Acceptance - Every route the client builds has a row. - Section subtotals and the grand total are recomputed **from the rows themselves**, not carried forward. - `Implemented`/`Tested` re-scored under the file's existing rule, with the wave's PRs cited per flipped row. - The `Auth` column stays derived from `x-auth-type`, and any row whose auth annotation was corrected this wave (`/api/user/identities` → Bearer, PR #93) reflects it. | open | documentation | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/109 | Sep 17, 2026, 9:04 AM | Sep 17, 2026, 9:04 AM |
| 104 | feat(sync-agent): move the agent's configuration into per-machine app settings | Split out of #56, which scoped this in **only if** the app-settings read/write half turned out to be solid. It was not — three of its six actions had UI that could not work and three had none — so the migration correctly waited. PR #102 fixes that half, which makes this the next step rather than a speculative one. ## Why per-machine settings are the right home `/help/app-settings` frames per-machine settings as exactly this case: a value that is meaningful on **one machine only**. The Document Sync Agent's sync-folder path is the textbook example — it is a local filesystem path, and syncing it to another Mac would point that Mac at a folder that may not exist. Today the agent keeps its configuration in `UserDefaults`, which means: - it does not survive a clean reinstall - a second Mac has to be configured from scratch with no indication that is expected - the Devices pane can show you a machine and tell you nothing about what that machine is actually doing ## What moves The agent's state, roughly: enabled/disabled, the sync folder path (as a **security-scoped bookmark**, not a plain string — a sandboxed app cannot reopen a user-chosen folder from a path alone), last-sync timestamp, and whatever conflict policy it holds. ## The parts that need care **Bookmarks are machine-local by definition.** A security-scoped bookmark resolved on another Mac is meaningless. So the stored shape should be the bookmark *plus* a human-readable path for display, and the receiving machine should treat a foreign bookmark as "not configured here" rather than attempting to resolve it. **Migration must be one-way and idempotent.** Read `UserDefaults`, write the per-machine document, keep reading `UserDefaults` as a fallback until a successful write is confirmed. A migration that runs on every launch and overwrites a newer server value with a stale local one is worse than no migration. **The writes are compare-and-set.** PR #102 established that the app-settings family requires `baseVersion` and answers `409` with the current document on a stale write. The agent runs in the background, so it is the most likely thing in the app to lose that race — it needs a re-base-and-retry, not a failure toast. **Do not put it in shared settings.** Shared is seeded to a new machine on first sign-in; a sync-folder path arriving pre-filled from a different Mac is the specific failure this whole feature exists to avoid. ## Acceptance - The agent reads its configuration from per-machine app settings, falling back to `UserDefaults` only until the first successful write. - A folder chosen on one Mac does not appear as configured on another. - A `409` on the agent's own write re-bases and retries rather than surfacing. - The Devices pane can show whether a given machine has the agent configured. - BDD quartet, including the boundary where `UserDefaults` is empty (a fresh install) and the one where both sources have a value. | closed | enhancement | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/104 | Sep 16, 2026, 8:03 PM | Sep 16, 2026, 8:34 PM |
| 103 | refactor(kit): APIError discards structured error bodies, so 400/409 details are unreachable | Surfaced twice in one day, from opposite ends of the codebase. Filing it once. ## The gap `APIError` keeps only the **decoded `{error}` string** from a failed response. Everything else in the body is discarded at `APIClient.swift:234`, before any caller sees it: ```swift case 400: return .badRequest(serverMessage: serverMessage) ``` So an error body that carries *structured* information — the thing the client needs in order to do anything but show a sentence — cannot reach the code that would act on it. ## Two concrete callers, both already written around it **1. The list-schema destructive-change guard** (PR #87, closing #85). `PUT /api/lists/{id}/schema` refuses to drop a column that still holds row data, answering `400` with a **`propertiesWithData` array naming the columns**. That is a question, not a malfunction — the UI should name the columns and offer the confirmation. `ListSchemaConflictDTO` models the full shape and nothing can populate it, so the user gets the server's sentence and no column list. **2. The app-settings compare-and-set conflict** (PR #102, closing #56). The app-settings family is compare-and-set: a stale `baseVersion` answers **`409` with the `current` document attached**, so the client can show what changed, or merge, or re-base and retry. Today it can only report that a conflict happened. Neither is a hypothetical. Both are shipped code with a comment explaining why the better behaviour is absent. ## Why it has not been fixed in passing `APIError.badRequest(serverMessage: String?)` and its siblings carry an associated value that every `case .badRequest(let message)` in the codebase pattern-matches. Adding a second associated value breaks every one of those sites, so it is not a change to slip into a feature PR — which is exactly why both PRs flagged it and moved on. ## Suggested shape Keep the body alongside the message rather than replacing it: ```swift case badRequest(serverMessage: String?, body: Data?) ``` …with the existing pattern matches updated, **or** — less invasive — a parallel accessor that does not change the cases: ```swift extension APIError { /// The raw response body, when the failure carried one. var responseBody: Data? { … } /// Decodes the body as `T`, or `nil` when it was absent or did not match. func details<T: Decodable>(as type: T.Type) -> T? } ``` The second keeps every call site compiling and gives both callers what they need. Worth weighing which reads better before writing it — the first is more honest about where the data lives, the second is far cheaper. ## Acceptance - `ListsService.updateSchema` surfaces the `propertiesWithData` column names, and the schema editor's confirmation names them. - `AppSettingsService` surfaces the `current` document from a `409`. - A decode failure on an error body degrades to today's behaviour — a message with no details — rather than masking the original error. - BDD quartet on the accessor: a body that decodes, a body that does not, an error with no body at all, and a non-HTTP failure. | closed | enhancement | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/103 | Sep 16, 2026, 8:03 PM | Sep 16, 2026, 8:14 PM |
| 98 | build: dev does not compile on Xcode 27 — SwiftUI.Document now collides with InterlinedDomain.Document | **`dev` does not build on Xcode 27.** This blocks every open PR and every new one. ## What happened Xcode was updated on this machine at **12:23 on 2026-09-15**, mid-session — builds from the same commit succeeded before it and fail after. The macOS 27 SDK adds a **`Document` protocol to SwiftUI**: ``` SwiftUI.Document:4:17: note: found this candidate public protocol Document : ReadableDocument, WritableDocument { ``` `InterlinedDomain` has exported a `Document` **struct** since M4. Any file that imports both — which is every documents-feature view — now has an ambiguous `Document` in scope. ## The errors Nine sites, six files, all in `App/Features/**`: ``` App/Features/AI/AIDocumentSheet.swift:25:21 App/Features/Documents/ConflictBannerView.swift:18:27 App/Features/Documents/DocumentEditorView.swift:24:27 App/Features/Documents/DocumentsListView.swift:15:20 App/Features/Documents/DocumentsListView.swift:24:20 App/Features/Documents/DocumentsListView.swift:106:19 App/Features/Documents/DocumentsRootView.swift:436:21 App/Features/Documents/DocumentsRootView.swift:452:15 App/Features/Documents/DocumentTemplatePickerView.swift:39:21 ``` Every one is `import SwiftUI` + `import InterlinedDomain` with a bare `Document` used as a type. Note the view **models** are unaffected — they import `Foundation`, `Observation` and `InterlinedDomain`, not SwiftUI. It is exactly the SwiftUI-importing files, which is what makes the diagnosis unambiguous. ## Fix Qualify the domain type at the use sites: `InterlinedDomain.Document`. Considered and rejected: - **Renaming the domain type.** `Document` is the correct domain name and it is right across the Kit/Domain/Persistence layers and their tests. Renaming a core model to dodge a name collision in one consumer is the tail wagging the dog. - **A module-level `typealias ILDocument = InterlinedDomain.Document`.** Shorter at the use sites, but it introduces a second name for one concept, and the next reader has to learn that `ILDocument` and `Document` are the same thing. Explicit qualification says exactly what it means. - **Dropping `import SwiftUI`.** Not possible; these are SwiftUI views. ## Worth knowing for the future This is a **standing hazard, not a one-off**. Any domain type sharing a name with a SwiftUI symbol is one SDK update away from the same break. Current domain models at risk by name: `Document`, `Message`, `List`-adjacent names, `Section`-adjacent names. Nothing to do pre-emptively, but the diagnosis is worth writing down so the next occurrence takes minutes rather than an afternoon. ## Acceptance - `xcodebuild build -scheme InterlinedList -destination 'platform=macOS'` → `** BUILD SUCCEEDED **` on Xcode 27. - No domain model is renamed. - The full gate is green. | closed | bug | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/98 | Sep 16, 2026, 7:41 PM | Sep 16, 2026, 7:45 PM |
| 95 | bug(notifications): the client's type vocabulary is bare tokens, the server's is prefixed — every notification maps to .other | Found while probing #77 on 2026-09-15. **No notification on the test account maps to a typed `NotificationKind`.** All 37 fall to `.other`. ## The mismatch `NotificationKind.init(rawValue:)` (`Packages/InterlinedDomain/Sources/InterlinedDomain/Models/NotificationKind.swift:64`) matches **bare** tokens: ```swift case "dig": self = .dig case "reply": self = .reply case "mention": self = .mention case "follow_request": self = .followRequest … ``` The server sends **prefixed** ones. Captured live from `GET /api/notifications?scope=all&limit=100`: | Live `type` | Count | Client maps to | |---|---|---| | `message_dig` | 23 | `.other("message_dig")` | | `message_push_commentary` | 5 | `.other(…)` | | `message_push_plain` | 3 | `.other(…)` | | `message_mention` | 3 | `.other(…)` | | `integration_reconnect` | 3 | `.other(…)` | Not one of the eight typed cases is reachable by any notification this account has. ## What that breaks **Every row renders as a generic bell.** `NotificationRowCopy` switches on the kind for its icon and its sentence; `.other` is the fallback, so `hand.thumbsup.fill` for a dig, `at` for a mention and the rest are dead code. **The deep-link router's per-kind branches never fire.** `NotificationDeepLinkRouter` (`App/Composition/NotificationDeepLinkRouter.swift:78`) reads the target id only under `case .dig, .reply, .mention` — so tapping a notification cannot resolve to its message through that path. The information is right there on the wire, and richer than the client knows: ```json {"type":"message_dig", "routePath":"/message/dd9ec178-…/thread", "target":{"messageId":"dd9ec178-…","listId":null,"orgId":null}, "metadata":{"type":"message_dig","eventAt":"…","actorUserId":"c65092fa-…", "sourceMessageId":"dd9ec178-…"}} ``` `target.messageId`, `routePath` and `metadata.actorUserId` are all populated. A push with commentary even carries the commentary text. ## Two kinds the client does not have `message_push_plain` and `message_push_commentary` are distinct notification types — a plain repost and a repost with a comment — and the client models neither. They are not "dig" or "reply"; they need their own cases so the copy can say what happened (and, for commentary, show it). ## `direct_message` exists as an event `GET /api/user/notification-preferences` lists it in the catalogue: ``` dig · push · follow · mention · reply · direct_message · share · integration_reconnect ``` So the server **does** emit DM notifications, which answers the open question in **#77**: the DM deep-link seam has a natural producer and it is a mapping change, not a backend ask. The exact `type` token on a DM notification row is still unconfirmed — the test account has never received a DM — so that one should be added cautiously and confirmed against a real row. ## Fix 1. Map the real tokens: `message_dig`, `message_mention`, `message_push_plain`, `message_push_commentary`, `integration_reconnect`. 2. Add `.push(hasCommentary:)` (or two cases) and `.integrationReconnect`. 3. Keep accepting the **bare** tokens too. They cost nothing, and this issue is evidence that the vocabulary is not fixed — matching only the currently-observed spelling would repeat the mistake in the other direction. 4. Widen the deep-link router to read `target.messageId` / `routePath` for every message-shaped kind rather than an allowlist of three. 5. `.other` stays, and stays tested — the point is that it should be rare, not gone. ## Acceptance - Every notification type the account actually holds maps to a typed case. - A notification row shows the icon and sentence for what happened. - Tapping one resolves to its message. - Contract test against the **captured** payloads above, not fabricated ones — this defect exists because the fixtures used the tokens the client expected. | closed | bugparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/95 | Sep 15, 2026, 12:17 PM | Sep 16, 2026, 7:30 PM |
| 91 | upstream: a profile location can be set through the API but never cleared | Found while probing #57 on 2026-09-15. **A profile location can be set through the API and cannot be removed through it.** ## Evidence `PATCH /api/user/update` is the only user-mutation route (`OPTIONS /api/user` → `GET, HEAD, OPTIONS`; there is no `/api/user/location`, `/api/user/profile` or `/api/user/settings`). Setting works: ``` PATCH /api/user/update {"latitude":47.6062,"longitude":-122.3321} → 200 {"message":"User updated successfully", …} GET /api/user → "latitude":47.6062,"longitude":-122.3321 ``` Every form of *unset* is refused: | Body | Result | |---|---| | `{"latitude":null,"longitude":null}` | **400** `latitude must be a number between -90 and 90` | | `{"latitude":"","longitude":""}` | **400** same | | `{"latitude":"null","longitude":"null"}` | **400** same | | `{"latitude":false,"longitude":false}` | **400** same | | `{"latitude":-999,"longitude":-999}` | **400** same | And every speculative "clear" key is accepted-and-ignored — a `200` that changes nothing, which is the worst of both worlds for a caller trying to honour a deletion: | Body | Result | |---|---| | `{"clearLocation":true}` | 200, coordinates unchanged | | `{"clearLocation":"true"}` | 200, coordinates unchanged | | `{"location":null}` | 200, coordinates unchanged | | `{"cleared":true}` | 200, coordinates unchanged | `DELETE /api/user/location` → **404**. ## The `cleared` field is not what #57 guessed #57 inferred that `UserDTO.cleared` was the clear signal, on the strength of the test account reading `"cleared":true` with null coordinates. That was **correlation, not mechanism**: setting a location leaves `cleared` at `true`, and writing `cleared:true` does not clear the location. In the live OpenAPI, `cleared` sits among `spamScore`, `spamReasons`, `signupIp`, `signupUserAgent`, `signupReferrer` — it reads as a moderation/anti-spam field that happens to share a name with what a location-clearing flag would be called. Recording that explicitly so the next sweep does not re-derive the same wrong inference. ## Why this is more than an inconvenience The coordinates are published on a **public profile**. A field that can be set and not unset is a privacy defect, not a missing feature: a user who publishes an approximate home location has no way to take it back. `/help/settings` documents the field as *"Optional location for your profile"* and describes **no clear action** — so this is not a macOS gap. The web appears not to offer one either. ## What this blocks **#57 cannot ship its setter.** Shipping set-without-clear would make the privacy problem strictly worse: macOS would become a way to publish a location that cannot be removed from anywhere. #57 is re-scoped to the read-only half — showing a location set elsewhere, so it is at least *visible* — with set and clear landing together once this exists. ## The ask Either: 1. **Accept `null` for `latitude`/`longitude`** on `PATCH /api/user/update` and treat the pair as cleared, or 2. add **`DELETE /api/user/location`**, or 3. name whatever mechanism the web uses, if one exists and this probe missed it. Whichever it is, the client needs it to be a documented shape rather than a guess — a wrong clear payload that returns `200` and changes nothing is exactly the failure that leaves a location published while the user believes it was removed. ## Note on the test account This probe **left a location set** on the `.env` contract-test account (`messenger`, 47.6062 / −122.3321) because it could not be undone. That is not a side effect of carelessness — it *is* the bug, reproduced. It needs clearing by whatever out-of-band means exists (direct database access, or the mechanism this issue is asking for). | open | bugparityblocked | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/91 | Sep 15, 2026, 11:47 AM | Sep 16, 2026, 8:06 PM |
| 85 | bug(lists): the schema wire contract is a DSL string client-side and an object server-side — create, read and save are all broken | 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: ```json 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 ```json "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. | closed | bugparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/85 | Sep 15, 2026, 10:57 AM | Sep 16, 2026, 7:30 PM |
| 84 | bug(persistence): the sync outbox orders by a non-total key, so same-instant changes can replay out of order | Found while fixing #82. Split out rather than folded in, because the fix is a SwiftData schema change and #82 is test hygiene. ## The defect The document sync outbox is a FIFO queue whose order is established by a **non-total** sort key. - `SwiftDataDocumentStore.enqueueOutbox` stamps `enqueuedAt: Date()` — `Packages/InterlinedPersistence/Sources/InterlinedPersistence/Stores/SwiftDataDocumentStore.swift:166` - `SwiftDataDocumentStore.outboxEntries` sorts by **that field alone** — `…/SwiftDataDocumentStore.swift:176` ```swift FetchDescriptor<OutboxEntryRecord>( sortBy: [SortDescriptor(\.enqueuedAt, order: .forward)] ) ``` Two entries stamped in the same instant tie, and `SortDescriptor` specifies no tiebreak, so their relative order is whatever the store returns. For a queue whose entire contract is *replay these changes in the order they happened*, that is a correctness hole rather than a cosmetic one: `.updateDocument` replayed before the `.createDocument` it depends on, or a `.deleteFolder` overtaking the `.renameFolder` ahead of it. ## The evidence that this is known, not theoretical The tests already work around it, and say so: ```swift try await store.enqueueOutbox(.deleteDocument(id: "first")) // SwiftData uses Date() at enqueue — sleep briefly so timestamps differ. try await Task.sleep(nanoseconds: 5_000_000) ``` — `Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/SwiftDataDocumentStoreTests.swift:181` and again at `:183` and `:246` (the latter sleeping 2 ms between **each** of six enqueues). Four sleeps whose only job is to keep the production sort key total. A test that has to slow the system down to make the assertion true is describing a defect in the system, not a defect in the test. ## Why it has not obviously bitten Each `enqueueOutbox` performs a `context.save()`, which costs far more than `Date()`'s resolution — so in practice consecutive enqueues usually do get distinct stamps. "Usually" is the problem: the failure mode is silent, data-shaped, and would present as a sync bug with no stack trace. ## Fix Add a monotonic sequence to `OutboxEntryRecord` and sort by it, keeping `enqueuedAt` as the human-facing timestamp it already is: ```swift sortBy: [SortDescriptor(\.sequence, order: .forward)] ``` - The column is additive with a default, so SwiftData lightweight migration covers existing stores — but **confirm that against a populated store**, not only an in-memory one. - The counter must be derived from the store (`max(sequence) + 1` inside the same save), not from a process-local static, or two app launches restart the numbering. - Once it lands, **delete the four sleeps** in `SwiftDataDocumentStoreTests` — they are the regression test: if enqueuing six changes back-to-back with no delay still reads back in order, the fix works. ## Acceptance - `outboxEntries()` returns insertion order for entries enqueued with no delay between them. - The six-change round-trip test at `SwiftDataDocumentStoreTests:246` passes with **zero** sleeps. - An existing on-disk store opens without loss after the migration. - BDD quartet: happy (ordered replay), invalid (unreadable payload still dropped, order of the rest preserved), upstream-failure (a failed dequeue leaves order intact), boundary (empty outbox; entries enqueued in the same millisecond). | closed | bug | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/84 | Sep 15, 2026, 10:52 AM | Sep 16, 2026, 7:47 PM |
| 82 | test(flake): App target intermittently reports 1 failure; nine test files use real sleeps | ## Observations Two independent sightings of the same pattern, neither reproducible on re-run. | When | What | |---|---| | PR #73 (2026-09-13) | First App-target run after merging `origin/dev` reported **1 failure**. Name not captured. Did not reproduce in **14 consecutive** full-gate runs. | | Phase 2 docs PR (2026-09-14) | First App-target run reported **1 failure** (`Executed 954 tests, with 1 failure`, `** TEST FAILED **`). Name not captured. Did not reproduce in **3 consecutive** re-runs, nor in the 3 full-gate runs earlier that session. | Both times the change under test was incapable of causing it — PR #73 touched Settings/preferences, and the 2026-09-14 run was **documentation-only** (zero `.swift`, `.pbxproj` or `.plist` changes). That is what makes this worth filing rather than attributing to the work in flight. ## The likely cause Nine test files use real sleeps rather than waiting on a post-condition: | File | Sleep calls | |---|---| | `AppTests/ExportViewModelTests.swift` | **13** | | `Packages/InterlinedPersistence/Tests/.../DocumentSyncEngineTests.swift` | 4 | | `Packages/InterlinedPersistence/Tests/.../SwiftDataDocumentStoreTests.swift` | 3 | | `AppTests/DMThreadViewModelTests.swift` | 3 | | `AppTests/TagCompletionViewModelTests.swift` | 2 | | `AppTests/SearchViewModelTests.swift` | 1 | | `AppTests/DirectMessagesListViewModelTests.swift` | 1 | | `AppTests/CurrentUserStoreTests.swift` | 1 | | `Packages/InterlinedKit/Tests/.../FileLogTests.swift` | 1 | A fixed sleep passes on an unloaded machine and fails when the first run of a session competes with indexing, a cold build, or another Xcode process — which matches both sightings landing on the **first** run after other work. There is already a precedent in this repo: the G20 tag-completion test was flaky in the full gate for the same reason. The fix applied there is the one to generalise — **`Task.yield()` is not a barrier for `Task.sleep`; poll for the post-condition instead.** ## Suggested work 1. **Capture the name first.** Add `-resultBundlePath` to the gate's App-target run, or run with `xcresulttool`, so the next occurrence names the test instead of costing another unreproducible sighting. This is the highest-value step and is cheap. 2. Convert the sleeps to post-condition polling, heaviest file first (`ExportViewModelTests`, 13 sleeps). 3. Consider a repeat-until-failure run (`-test-iterations`) over the App target to force a reproduction rather than waiting for one. ## Acceptance - The gate names the failing test when it fails. - No App-target test depends on a fixed wall-clock sleep for correctness. - 20 consecutive full-gate App-target runs clean. ## Why not just ignore it A gate that fails ~1 run in 5 for unattributable reasons trains everyone to re-run until green, which is exactly how a real regression gets waved through. The project's own rule — *"test regression in a package you did not touch → investigate; do not paper over"* — is unenforceable while this stands. --- ## Implementation plan (added 2026-09-15) The two sightings and the nine sleep-bearing files are symptoms of **two distinct causes**, and only one of them is a test-hygiene problem. Splitting them is what makes this fixable rather than endlessly re-tuned. ### Cause 1 — the event buses register subscribers asynchronously (the real race) All four feature buses (`ListsEventBus`, `ComposerEventBus`, `NotificationsEventBus`, `DirectMessagesEventBus`) carry an identical private actor and register the continuation **inside a `Task`**: ```swift return AsyncStream { continuation in Task { await self.storage.register(id: id, continuation: continuation) } } ``` So `events()` returns a stream that is **not yet in the subscriber table**. A caller that subscribes and then immediately writes can miss its own event, and no number of `Task.yield()`s closes the window — the wait is on actor scheduling, not on cooperative yields. Two tests paper over exactly this with a fixed `10ms` sleep commented *"give the subscription a beat to register"* (`DirectMessagesListViewModelTests:267`, `DMThreadViewModelTests:204`). Both then `await fulfillment(of:timeout: 1.0)` — so when the beat is not enough, the failure is a **1-second expectation timeout**, i.e. exactly the "1 unnamed failure, does not reproduce" signature in the table above. This is **not only a test problem.** In the app, a view that subscribes in `.task { }` and immediately triggers a write can drop its own event — the unread badge that occasionally does not move. **Fix:** extract the four copies into one `EventBusStorage<Event>` that registers **synchronously** under a `Mutex`, inside the `AsyncStream` build closure (which `AsyncStream` invokes synchronously during init). `post` broadcasts synchronously too. Expose `subscriberCount` so a test can assert a subscription is live instead of waiting for one. ### Cause 2 — fixed wall-clock waits for asynchronous post-conditions `ExportViewModelTests` (13 sleeps) waits a flat `100ms` for a fire-and-forget `Task`; `SearchViewModelTests:162` polls for at most `50ms` and then **falls through silently**, so a slow machine fails on the assertion below rather than on the wait that actually expired. **Fix:** one shared helper file, `AppTests/Support/AsyncSettle.swift`: - `settle(until:)` — polls for the post-condition, generous 5s ceiling (a satisfied condition exits on the first pass, so the ceiling is free), `XCTFail` at the **caller's** file and line when it expires. - `settleQuiet(for:)` — the deliberately short bounded wait, for assertions that something did *not* happen. Absence claims cannot exit early, so this one is kept small on purpose. - `settledValue(of:)` — waits for a counter to stop moving before taking a baseline. Needed by `test_givenRunningPoll_whenStopped_thenNoFurtherUpdatesLand`, which sampled the count the instant after `stopPolling()`: a poll already in flight still records, and the old version failed a correctly-cancelled loop. `CurrentUserStoreTests` already had a private version of `settle(until:)` and `TagCompletionViewModelTests` already had the G20 fix; both are re-pointed at the shared helper so there is one idiom, not three. ### Item 1 of the issue — name the test (done first, and it paid immediately) - `scripts/gate.sh` — the whole E2E gate in one command, with `-resultBundlePath` per App-target run and an `xcresulttool` pass that prints the failing test identifiers. `scripts/gate.sh app 20` is the flake-hunt mode. - `.github/workflows/ci.yml` — `-resultBundlePath`, a `::error::` annotation naming the failing tests, and the result bundle uploaded as an artefact on failure. - The gate's Decision 0003 grep is **anchored to column 0**. The unanchored form in the checklist matches the prose in file-header comments that say a file does *not* import the kit, and reports a violation on a compliant tree. This machinery named a failure on its first run (a defect in one of the new tests: `_ = bus.events()` drops the stream, whose `deinit` correctly unregisters). That is the whole point — a count is not a bug report. ### Deliberately **not** in this PR The four sleeps in `InterlinedPersistence`'s outbox tests are a different animal: they exist to force distinct `Date()` timestamps, because `outboxEntries()` sorts **only** by `enqueuedAt`, which is not a total order. That is a latent **FIFO-ordering defect in a sync outbox** — out-of-order replay of document changes — not a test race, and fixing it means adding a monotonic sequence column to `OutboxEntryRecord` (a SwiftData schema change). Filed separately rather than smuggled into a test-hygiene PR. `FileLogTests:73` and `SwiftDataDocumentStoreTests` are left alone for the same reason: their `sleep` calls are polling **ticks** inside correct wait loops, not fixed waits. ### Acceptance mapping | Criterion | How it is met | |---|---| | The gate names the failing test when it fails | `scripts/gate.sh` + CI `::error::` annotation + uploaded bundle | | No App-target test depends on a fixed wall-clock sleep | zero `Task.sleep` in `AppTests/` outside the polling ticks inside `AsyncSettle` | | 20 consecutive clean App-target runs | `-test-iterations 20 -run-tests-until-failure` | | closed | bug | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/82 | Sep 14, 2026, 4:07 PM | Sep 16, 2026, 7:30 PM |
| 81 | feat(lists): saved list views — shared + personal views, forking, defaults (G40) | Surfaced by the 2026-09-14 coverage re-measure. **No previous issue or planning-doc revision mentions this feature at all** — it was missed by every prior parity sweep. ## The gap Five live operations the client does not build. All are `x-auth-type: sync-token` (Bearer-reachable) and `x-subscription-tier: free`, so this is buildable today and gates on nothing. | Operation | What the spec says | |---|---| | `GET /api/lists/{id}/views` | *"Every shared view on the list, plus this user's own personal views."* | | `POST /api/lists/{id}/views` | Creates a saved view — `{name, scope, config, isDefault}` | | `POST /api/lists/{id}/views/{viewId}` | *"Forks a shared view into a personal copy owned by the caller — the escape hatch."* | | `PUT /api/lists/{id}/views/{viewId}` | Updates a view's name, config or default flag | | `DELETE /api/lists/{id}/views/{viewId}` | Removes a view | ## Why it matters The shape implies a real collaboration model, not a cosmetic preference: - **shared** views belong to the list and everyone with access sees them - **personal** views belong to the caller - **fork** exists as a deliberate *"escape hatch"* — you can take someone else's shared view and make it yours rather than being stuck with how the list owner arranged it - `isDefault` is per-user, so two people can open the same list and correctly see different default arrangements macOS currently has one hard-coded arrangement per list. On a shared list the owner's choices are simply not visible to collaborators. ## ⚠️ Probe before modelling — `config` is a string `config` is declared `{"type": "string"}`, **not** an object, so the schema says nothing about its grammar. It presumably encodes column order / visibility / sort / filter, but that is an inference. **Capture a real saved view from the web app before writing any decoder.** This repo has a documented history of exactly this failure — G21 link metadata (nested `metadata` decoded to all-nil, so every timeline link rendered host-only) and G25 org members (`OrganizationMemberDTO` required `userId` where live rows key by `id`). Both had green tests against fabricated fixtures. Do not add a third. Also worth confirming: whether `scope` is the shared/personal discriminator and what its token set is. ## Sequencing Additive — nothing currently shipped is wrong without it. Suggest **after the account cluster** (#44 → #47 → #46 → #45). ## Acceptance - Views listed, created, updated, deleted, and forked from macOS. - Shared vs personal is visually distinct; the fork affordance is discoverable. - `isDefault` is honoured per user on list open. - `config` is modelled against a **captured** payload, with a contract test that fails on a renamed key rather than degrading to `nil`. - BDD quartet across Kit / Domain / App. Recorded as **G40** in `work-consolidation.md` §1f. | closed | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/81 | Sep 14, 2026, 3:56 PM | Sep 16, 2026, 8:08 PM |
| 80 | bug(notifications): scope=tray returns unread-only, where the web bell shows the last N | Found during PR #73 (G35), outside that issue's scope. Recorded during the 2026-09-13 tracker reconciliation. ## The defect `GET /api/notifications?scope=tray` returns **unread notifications only**. The web bell shows the **last N regardless of read state**. So on macOS the tray empties as you read it. On the web it retains recent history. Same feature, different behaviour — and the macOS one is the surprising one: a notification you glance at vanishes, with no way to get back to it from the tray. Already documented at the endpoint: - `Packages/InterlinedKit/Sources/InterlinedKit/Endpoints/NotificationsEndpoint.swift:27` — *"with `scope=tray` the server currently ignores `limit` and returns unread…"* So the client knows; nothing acts on it. ## Compounding: the tray limit is now user-configurable PR #73 shipped `notificationTrayLimit` in View Preferences — the user can now set how many tray items they want. Against a route that **ignores `limit`** and filters to unread, that preference is currently close to inert. Shipping a control the server does not honour is its own small defect. ## Two candidate fixes — probe first 1. **A different scope or parameter already does this.** Probe `GET /api/notifications` with other `scope` values and with `limit` under each before concluding anything. If a read-inclusive scope exists, this is a one-line change. 2. **No such scope exists** → backend ask: *the web bell shows the last N regardless of read state; which parameter produces that, and is `limit` honoured under it?* Folds into #58. Do **not** work around it by fetching a wider page and filtering client-side. That is exactly the failure mode PR #69 fixed for the DM inbox — client-side reconstruction is only ever as complete as the page fetched. ## Acceptance - The tray matches the web bell: last N, read and unread. - `notificationTrayLimit` visibly changes what the tray shows. - If the route cannot do it, the backend ask is filed and the preference is honestly labelled until then. --- ## Implementation plan (added 2026-09-15) — probe done, it is candidate 1 The issue said *"probe first"* before concluding anything. Done, and the answer is the cheap one: **a read-inclusive scope already exists and honours `limit`.** Live, on the test account: ``` ?scope=tray → 1 item (the single unread one) ?scope=tray&limit=5 → 1 item (limit ignored) ?scope=all&limit=3 → 3 items (read and unread, newest first) ?scope=all&limit=10 → 10 items ?scope=all → 20 items (the account's stored notificationTrayLimit) ``` `unreadCount` comes back under **both** scopes, so the badge is unaffected. No backend ask is needed, and nothing folds into #58. ### The work 1. **`Notifications.tray(scope:limit:)` defaults to `"all"`.** The unread-only scope stays reachable for anything that genuinely wants it — it is just no longer what the tray asks for. 2. **Delete the client-side `prefix(limit)` in `NotificationsService.tray(limit:)`.** It existed only because `scope=tray` ignored the parameter, and it made `notificationTrayLimit` *appear* to work while the rows it trimmed were unread-only — so the preference could only ever shrink an already-wrong list. Not re-added as a belt-and-braces cap either: a client-side trim over a correctly-limited response is invisible when it agrees with the server and wrong when it does not. 3. **Correct the two stale notes** that describe `scope=tray` ignoring `limit` as a standing constraint (`NotificationsEndpoint.swift:27`, and the G35 correction block in `NotificationsService.swift`). No workaround of the kind the issue warned against: nothing fetches a wider page and filters client-side. The server does the limiting. ### Tests - The endpoint sends `scope=all` by default and still accepts `scope: "tray"` explicitly. - `limit` travels alongside the scope; absent `limit` omits the parameter so the server applies the stored preference. - **A fully-read account still sees its tray** — the behaviour this whole issue is about. - The server's page is rendered as-is rather than second-guessed. - `unreadCount` stays server-authoritative regardless of row count. ### Acceptance - ✅ The tray matches the web bell: last N, read and unread. - ✅ `notificationTrayLimit` visibly changes what the tray shows — now because the *server* honours it. - n/a — no backend ask needed. | closed | bugparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/80 | Sep 13, 2026, 10:43 PM | Sep 16, 2026, 7:30 PM |
| 79 | feat(compose): CreateMessageRequest cannot send linkedInTargets or linkedInLinkAsFirstComment | Found during PR #71, recorded during the 2026-09-13 tracker reconciliation. ## The gap The web's message-create body sends two LinkedIn fields the macOS client does not model: - `linkedInTargets` — **which** LinkedIn destination(s) to publish to (personal profile vs one or more company pages) - `linkedInLinkAsFirstComment` — post the link as the first comment instead of in the body, the standard LinkedIn reach tactic `CreateMessageRequest` (`Packages/InterlinedKit/Sources/InterlinedKit/DTOs/MessageDTO.swift`) models neither. It carries `crossPostToLinkedIn: Bool?` — a yes/no with **no destination choice**. ## What that means today A user with a personal profile *and* one or more assigned company pages has no way to choose from macOS. The server applies its default. Per `/help/organizations`, an assigned member's default destination is the **company page** — so "cross-post to LinkedIn" can publish somewhere the user did not intend and cannot redirect. PR #67 already fixed the *display* half of this confusion: `LinkedInTargetDTO.kind` had the wrong taxonomy (`personal`/`org` instead of the real `personal`/`orgPage`/`personalPage`), which made the composer's "Posting as …" line name a personal profile while the server published to a company page. The line is now correct — but it is still only a **readout**, not a control. ## Why it was not built in PR #71 It needs the Integrations/Organizations projection to exist — the composer has to know which targets are available before it can offer them. #47 (Integrations) and the org-LinkedIn work in PR #67 are the supply side. Half-building a picker over an incomplete target list was the worse option. ## Dependencies Sequence **after** #47. PR #67 already ships `LinkedInPostingTargets.defaultDestination` and the org-LinkedIn routes, so much of the domain supply exists; what is missing is the personal-side inventory and the composer control. ## Acceptance - The composer offers the real destination list, defaulting to what the server would choose anyway. - `linkedInLinkAsFirstComment` is a visible toggle. - Both ride the create body; a scheduled post carries them too. - The "Posting as …" line reflects the **chosen** destination, not the inferred default. | open | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/79 | Sep 13, 2026, 10:43 PM | Sep 16, 2026, 8:01 PM |
| 78 | feat(documents): live-cursor presence heartbeat is unbuilt | Deferred from PR #70 (G24), recorded during the 2026-09-13 tracker reconciliation. ## Scope Two routes the Documents feature does not call: - `POST /api/documents/{id}/presence` — announce/refresh presence - `DELETE /api/documents/{id}/presence` — clear it These back the web's live-collaborator indicator on a shared document. ## Why it was deferred G24's acceptance criteria were the tree, public documents, create-in-folder and move-to-folder. Presence is a genuinely separate feature with its own lifecycle problems, and bolting it onto that PR would have widened the review for no gain. ## What makes this more than two calls The routes are trivial; the **lifecycle** is where the work is: - **Heartbeat cadence** — how often to POST, and what the server's expiry window is. Unknown; needs a probe or a backend answer. - **Teardown on every exit path** — closing the editor, closing the window, quitting, sleeping, losing the network. A missed DELETE leaves a ghost collaborator on other people's screens, which is worse than showing nobody. - **App lifecycle** — presence should stop when the app is backgrounded or the machine sleeps, not keep announcing a user who walked away. - **Read model** — presence is only useful if the client can also *read* who else is present; confirm which route supplies that before building the write half alone. ## Suggested sequencing Worth doing **after** the account cluster (#44/#47/#46/#45), not before. It is polish on a collaboration surface whose more basic gaps are already tracked, and it is the kind of feature that looks broken when half-built. ## Acceptance - Presence announced on open, refreshed on a confirmed cadence, cleared on **every** exit path including crash-adjacent ones. - Other collaborators render from the read side. - A ghost-presence regression test: force-quit mid-session, assert the entry expires. | open | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/78 | Sep 13, 2026, 10:43 PM | Sep 17, 2026, 9:00 AM |
| 77 | feat(dm): nothing posts .directMessagesOpenMessage — the deep-link seam has no producer | Follow-up from PR #69 (G22), recorded during the 2026-09-13 tracker reconciliation. ## The gap PR #69 built and tested the DM deep-link **consumer**: - `App/Features/DirectMessages/DirectMessagesRootView.swift:79` — receives `.directMessagesOpenMessage` and opens the thread - `App/Features/DirectMessages/DirectMessagesRootView.swift:300` — the notification name The resolver is covered by tests. But **nothing posts the notification.** There is no direct-message `NotificationKind`, and no URL-scheme route produces one. The seam is live, correct, and unreachable. ## Why it was left It is the right half to build first — the consumer is what the feature needs regardless of which producer eventually arrives, and guessing the producer would have meant inventing a notification taxonomy the server does not confirm. ## What a producer would be, in likely order 1. **A DM `NotificationKind`** — if the server emits DM notifications, mapping that kind to this notification is the natural producer and makes the bell open the thread. 2. **A URL scheme route** — `interlinedlist://dm/{id}`, alongside the existing share-link routing. 3. **A push payload** — blocked behind #59 (can a notarized Developer-ID pkg receive APNs at all). ## First step Probe `GET /api/notifications` for a DM-shaped kind. If the server already emits one, this is a small mapping change. If it does not, this issue becomes a backend ask and should fold into #58. ## Acceptance - At least one real producer posts `.directMessagesOpenMessage`. - Opening from that producer lands on the right thread, tested end to end. - If no producer is reachable, the seam is documented as intentionally dormant rather than left looking wired. --- ## Probe result (2026-09-15) — the producer exists, and it is candidate 1 This issue's "first step" was: *"Probe `GET /api/notifications` for a DM-shaped kind. If the server already emits one, this is a small mapping change. If it does not, this issue becomes a backend ask and should fold into #58."* **It does.** `GET /api/user/notification-preferences` returns the server's own event catalogue: ``` dig · push · follow · mention · reply · direct_message · share · integration_reconnect ``` `direct_message` is a first-class notification event with `push` and `inApp` channels. So this is **candidate 1 — a DM `NotificationKind`** — and it does **not** fold into #58. ### What landed already PR #96 adds `NotificationKind.directMessage`, because the probe that found this also found a much larger defect: **the client's whole notification vocabulary was wrong.** It matched bare tokens (`"dig"`, `"mention"`) where the server sends prefixed ones (`"message_dig"`, `"message_mention"`), so all 37 notifications on the recon account fell to `.other` — filed and fixed as **#95**. ### What is still open here **The producer is deliberately not wired.** No DM notification has been observed — the recon account has never received a DM — so the exact `type` token on a DM row is unconfirmed. `.directMessage` accepts `direct_message`, `message_direct` and `dm`, and the deep-link post is the one step that waits for a real row. That is a smaller and better-defined remainder than this issue started with: not *"does a producer exist"* but *"confirm one token, then post the notification."* ### Remaining work 1. Receive a DM on the recon account and capture the notification row — the token, and whichever key carries the conversation or message id. 2. Tighten `NotificationKind`'s DM spellings to the real one. 3. Map that row's target to a DM conversation (`NotificationTarget` currently falls through to `routePath` for `.directMessage`, since inventing a metadata key would produce a target that silently points nowhere). 4. Post `.directMessagesOpenMessage` from the notification tap, and test it end to end. | open | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/77 | Sep 13, 2026, 10:42 PM | Sep 16, 2026, 8:06 PM |
| 76 | contract(dm): tighten the conversations decoder once a populated items[] is captured | Follow-up from PR #69 (G22), recorded during the 2026-09-13 tracker reconciliation. ## Current state — deliberate tolerance, not an oversight `GET /api/dm/conversations` shipped **without its populated shape ever being observed.** The test account's inbox is empty, and populating it (sending a real DM from the shared contract-test account) was out of scope. Rather than guess one spelling and risk a silent all-nil decode — exactly the G21 link-metadata defect — `DMConversationDTO` was made deliberately permissive: - accepts a **nested** row (`lastMessage` / `latestMessage` / `message`) **and** a **flattened** one (the row *is* the newest message, the natural `GROUP BY pairKey` output) - alternates for participant and unread count - `DMMessageResponse` accepts `{message}`, `{data}`, and the bare object - every field optional, so an unknown key degrades one field instead of failing the page Tests pin that tolerance in both directions. ## Why this should not stay Permissive decoding is the right call when the shape is unknown, and the wrong default once it is known. While it stands: - a **renamed** key degrades silently to `nil` instead of failing loudly - the tests assert tolerance rather than truth, so they cannot catch drift - nobody can tell from the code which spelling the server actually uses ## The work 1. Capture a populated `items[]` — send a real DM between two accounts, or get a payload sample from the backend. 2. Tighten `DMConversationDTO` to the real keys; **delete** the alternates. 3. Replace the tolerance tests with contract tests against the captured payload. 4. Keep exactly one deliberate escape hatch if the shape proves genuinely polymorphic — and say so in a comment. ## Acceptance - The decoder names one shape. - The fixture is captured, not fabricated. - A renamed key now **fails a test** instead of degrading to `nil`. --- ## Status (2026-09-15) — still blocked on the same capture, and here is why that was not resolved This issue's step 1 is *"Capture a populated `items[]` — send a real DM between two accounts, or get a payload sample from the backend."* Probed again on 2026-09-15. `GET /api/dm/conversations` is **still empty** on the `.env` recon account, so the populated shape remains unobserved and the permissive decoder stays exactly as it is. **Populating it was considered and not done.** It needs a *second* real account to send from — the recon account cannot DM itself — and creating one, or sending from a personal account, writes a message into someone's real inbox to satisfy a contract test. That is a heavier side effect than a read-only probe, and it is the owner's call rather than something to do unannounced. ### The tolerance is doing its job, and that is the danger Nothing about the current state is broken; the decoder accepts both the nested and flattened shapes and tests pin that tolerance in both directions. But as this issue says, permissive decoding is the right call when the shape is unknown and the wrong default once it is known — and *nothing forces the issue*. The pane works, so nobody looks. ### Two ways to unblock, in order of preference 1. **A payload sample from the backend** — one real `items[]` row, pasted into the issue. No writes, no second account, and it is the whole requirement. 2. **A second recon account.** Worth having for more than this: list sharing, org membership and follow requests all have "the other person" paths that are currently untestable end to end for the same reason. Folding this into the backend-ask list (#58) is reasonable if option 1 is the route. ### Adjacent finding The DM **notification** side did move: `/api/user/notification-preferences` confirms `direct_message` is a real server event (see #77 and #95). That says nothing about the conversations payload, but it is the same feature area and worth reading together. | open | parity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/76 | Sep 13, 2026, 10:42 PM | Sep 16, 2026, 8:06 PM |
| 75 | bug(lists): GET /api/lists/{id} envelope mismatch makes ListsService.detail(listId:) undecodable | 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:46` - `Packages/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** — `OrganizationMemberDTO` required `userId` where live rows key by `id`, 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>` in `ListsEndpoint` decoding 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}`), and `ListsService.detail(listId:)` unwraps it. - The **same sweep across every `Request<T>` in `ListsEndpoint`** that this issue asked for. It found four more: `create`, `update`, `refresh` (all `{message, data}`) and `publicList` (`{list, ancestors}` — a *third* envelope convention on the same resource). - The fabricated bare-`ListDTO` fixture is replaced by the captured payload, and a test pins the regression directly: decoding the real body as a bare `ListDTO` **must throw**. ### Acceptance - ✅ A live `GET /api/lists/{id}` response decodes. - ✅ The fixture is captured, not hand-written. - ✅ Every other `Request<T>` in `ListsEndpoint` decoding a bare DTO against an enveloped route is caught in the same sweep. | closed | bugparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/75 | Sep 13, 2026, 10:42 PM | Sep 16, 2026, 7:30 PM |
| 74 | upstream: no PATCH route exists for a scheduled post's content or destinations | placeholder | open | parityblocked | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/74 | Sep 13, 2026, 10:42 PM | Sep 16, 2026, 8:05 PM |
| 66 | epic: web-parity index - the remaining work to match interlinedlist.com | **This epic is a pointer, not a backlog.** The master plan — ordering, architecture, probe evidence, API shapes, the re-measure log, and the release path — lives in [`work-consolidation.md`](https://github.com/CompositeCode/interlinedlist-macos-native/blob/dev/work-consolidation.md). Its **§1f work index** maps every open item to its issue. | | | |---|---| | **The plan** | [`work-consolidation.md`](https://github.com/CompositeCode/interlinedlist-macos-native/blob/dev/work-consolidation.md) — why, and in what order | | **Execution** | Issues #38–#81 — live status; what PRs close | | **Per-endpoint coverage** | [`docs/api-coverage.md`](https://github.com/CompositeCode/interlinedlist-macos-native/blob/dev/docs/api-coverage.md) — implementation + verified reachability | When this file and an issue disagree, **the issue wins**. --- ## Why this epic was emptied (2026-09-14) It previously restated the entire backlog — the defect table, the parity-feature list, the decisions, the blocked items — while `work-consolidation.md` carried the same material in its §1/§2/§3. Two sources of truth, each drifting from the code and from each other. Between 2026-09-07 and 2026-09-13, seven PRs (#67–#73) shipped and **neither** was updated: the doc drifted ~330 tests and two API re-measures behind, and six issues both described as open had already shipped. Re-syncing them without removing the duplication would only have restarted the clock. So the duplication is cut in one direction. The doc is master; issues are execution; this epic points at both. ## Two claims this epic made that are now disproved **1. "45 operations are `x-auth-type: session` and reject Bearer — a hard ceiling on native parity."** Re-measured 2026-09-14: it is **32**, and **28 of those are routes a native client should never call** (24 `/api/admin/*`, 2 `/api/architecture-aggregates/*`, 2 `/api/stripe/*`). The genuinely limiting set is **four**: `GET /api/auth/accounts`, `POST /api/auth/switch`, `POST /api/auth/remove-account` (#60) and `POST /api/auth/send-verification-email`. **2. "The Dashboard is backend-blocked, not merely undecided."** No longer true. Both layout routes and all five widget routes are `sync-token` now, confirmed `200` under Bearer by live read-only probe: ``` GET /api/user/dashboard-layout -> 200 {"layout":null} GET /api/user/front-wall-layout -> 200 {"layout":null} GET /api/user/engagement -> 200 {"totalDigs":24,"totalPushes":6,...} GET /api/widgets/news -> 200 GET /api/widgets/markets -> 200 ``` Controls probed the other way — `/api/auth/accounts` and `send-verification-email` both **401** — so the four that remain really are session-only. **#61 is now a product decision only.** The invite/share **claim** routes also moved to `sync-token`, which makes PR #70's "genuinely out of reach" note on document-invite accept stale. ## Standing constraints (unchanged) - **SwiftUI only in the App target** — no AppKit / `NSViewRepresentable` under `App/**` without asking. - **Decision 0003** — no `import InterlinedKit` in `App/Features/**`, `App/Navigation/**`, `App/MenuCommands/**`. - **Probe before you decode.** `OPTIONS` proves the verb, never the envelope. This API has a documented history of shape drift, and three separate silent-decode defects (G21 link metadata, G25 org members, and the latent #75) all had green tests against fabricated fixtures. - **Verification is mandatory.** `.claude/skills/swift-engineer/assets/e2e-gate-checklist.md`, plus the BDD quartet. - **Same-PR doc-sync rule.** A PR that ships a G-item or closes a parity issue updates `work-consolidation.md` in that same PR. Baseline on `origin/dev` @ `01bc1a9` (2026-09-14): Kit **478** · Domain **946** · Persistence **140** · App **954**, all green. | open | parity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/66 | Sep 7, 2026, 12:09 PM | Sep 16, 2026, 8:05 PM |
| 65 | docs+chore: fold in the parity re-measure, reconcile the coverage matrix, prune worktrees | ## Summary Housekeeping that came out of the 2026-09-07 parity sweep. Individually trivial, collectively worth one PR. --- ## 1. Fold the parity re-measure into `work-consolidation.md` `docs/parity-refresh-2026-09` carries one unmerged commit (`cc57a46`) that: - corrects the **AI provisioning model** (no user API key — the single most important correction in that revision) - reconciles the doc with what actually merged (§1c verb defects and G15–G20 were still listed as open) - records the real test baseline - adds §1e for the 2026-09-06 account-cluster sweep Until it merges, the master planning doc contradicts the code on `dev`. **Merge it, then update it again** with the findings from this sweep (see item 2). ⚠️ `work-consolidation.md` is edited by several concurrent sessions. Merge it on its own, not bundled into a feature PR. ## 2. Record the 2026-09-07 findings in the master doc New facts this sweep established that are not yet written down anywhere: - The live spec is now **228 paths / 296 operations** (was 226/294 on 2026-09-05); the client builds ~180 request builders covering ~150 distinct operations. - **`accountStatus` exists on the User schema and is unmodelled by the client** — filed as its own issue. - **45 operations are `x-auth-type: session`** and reject Bearer. The ones that matter: invite/share **claim**, `send-verification-email`, `user/engagement`, `dashboard-layout`, `front-wall-layout`, and every `widgets/*` route. This makes the dashboard work backend-blocked, not merely undecided. - **List folders are still a live, documented web feature** (`/api/folders`, 4 ops) despite being removed from macOS in PR #19. - The **message permalink** shape in `MessagePermalink` is wrong — `/messages/{id}` redirects to `/login`; the public form is `/user/{username}/status/{id}`. - `GET /api/github/orgs` returns a **bare array**, not the usual `{message?, <resource>}` envelope. ## 3. Reconcile `docs/api-coverage.md` The matrix was last audited 2026-09-05 at **187 rows**. Since then PR #24, #25, #30, #31, #32, #33, #34 landed. Re-score it, and add rows for anything this sweep surfaced that it does not carry (`/api/folders`, `/api/users/{username}/documents`, `/api/auth/github/status`, `/api/github/orgs`, `accountStatus`). Record `x-auth-type` per row while you are in there — "the client cannot reach this under Bearer" is coverage information the matrix currently has no column for, and it is the difference between "not built" and "not buildable". ## 4. Prune stale worktrees Ten worktrees exist under `.claude/worktrees/`; most are for branches already merged: ``` appsettings-fix fix/app-settings-first-run-404 (merged, PR #30) composer-visibility feat/reply-repost-default-visibility crash-report feat/crash-reporting (merged, PR #33) link-meta feat/link-metadata-g21 msg-actions feat/message-actions-parity (merged, PR #32) parity-refresh docs/parity-refresh-2026-09 (unmerged — item 1) pr32-merge (detached HEAD) thread2-ai feat/ai-and-materialize (merged, PR #31) toolbar-leak fix/lists-new-folder-toolbar-leak verb-defects fix/live-verb-defects-v1-v7 (merged, PR #24) ``` Remove the ones whose branches are merged. ⚠️ **Check `git status` in each first** — a concurrent session may be working in one, and removing a worktree with uncommitted changes loses them. Leave `parity-refresh` alone until item 1 is done. ## 5. Local `dev` is behind `origin/dev` The local `dev` branch sits at `562ed8e`; `origin/dev` is at `42a63ff` with PRs #32, #33, #34 merged. Fast-forward before starting any work, or a new branch will be cut from a stale base. ## 6. Issues #27, #28, #29 were closed on 2026-09-07 — one caveat - **#27** "Message Features Need Implemented" — shipped as PR #32 (Reply, I Dig!, Link, Push, Push & Comment). ⚠️ The **Link** action shipped with a URL that redirects anonymous visitors to `/login`, and "Get embed code" was never built; both are now tracked in their own issue. Closing #27 was right — just make sure the remainder is not assumed shipped along with it. - **#28** "New Folder Still shows up in Lists" — closed as stale (the reporter's binary predated PR #19). Separately, whether list folders should come *back* is now its own decision issue. - **#29** "Create a Crash Report" — shipped as PR #33. No action needed here; recorded so the next sweep does not re-derive it. --- ## Acceptance criteria - The planning doc matches the code on `origin/dev`. - `docs/api-coverage.md` reflects every merged PR and carries auth-type information. - No worktree remains for a merged branch. ## Notes Docs work has its own gate: `.claude/skills/doc-engineer/assets/docs-quality-checklist.md`. --- ## Status (2026-09-15) **Items 1, 2, 3 — PR #97.** The unmerged commit this issue names (`cc57a46` then, `bd3324d` now) was found **committed and never pushed** on `docs/phase2-consolidation`, in a worktree. Same situation PR #83 was in. Pushed and PR'd rather than deleted around; **it needs review by whoever wrote it** — I verified only that it is clean, docs-only and does not break the build. 429 changed lines in `docs/api-coverage.md`, including the auth-type column this issue asked for. **Item 4 — done.** Of eleven worktrees, exactly one was for a branch already merged into `dev` (`acct-gating` / `fix/account-status-gating`, clean, PR #83) — removed. The ten listed here were already gone; the rest are live branches with open PRs. **Item 5 — done.** Local `dev` was behind again (`01bc1a9` vs `3461baf`). Fast-forwarded before any branch was cut, so every PR in this pass is based on current `dev`. **Item 6 — no action needed**, as recorded. ## What remains `bd3324d` predates this session, so the doc does **not** yet carry: - the corrections from the 2026-09-15 pass — the list-schema wire shapes (#85), the notification type vocabulary (#95), the identity provider tokens (#47), the notifications tray scope (#80) - the issues filed during it: **#84, #85, #91, #95** - the new test baselines (Kit 480 / Domain 1024 / Persistence 140 / App 984, depending on which PRs land) Better done *after* those PRs merge than speculatively now — which is the same reasoning this issue gives for merging the re-measure first and updating it again afterwards. Leaving this issue open for that second pass. | closed | documentation | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/65 | Sep 7, 2026, 12:09 PM | Sep 16, 2026, 7:30 PM |
| 64 | release: Mac App Store submission on an app-store branch - later | ## Summary The Mac App Store is a **later** distribution path, on a separate `app-store` branch, because removing Sparkle breaks the `.pkg`/`.dmg` channel that is shipping today. Filed now so the work is visible, not because it is next. **Do not start this until the notarized `.pkg` release has shipped.** ## Code changes (on an `app-store` branch) - [ ] **Remove Sparkle** — six locations, per `App-Dmg-Pkg-Deployment.md` §7a: - `project.pbxproj` (package reference, product dependency, build file) - `Package.resolved` (the `sparkle` pin) - `Info.plist` (`SUFeedURL`, `SUPublicEDKeyString`, the comment block) - delete `App/Composition/SparkleController.swift` - delete `App/MenuCommands/UpdatesMenuCommands.swift` - remove `sparkleController` and `UpdatesMenuCommands(…)` from `App/InterlinedListApp.swift` - [ ] **Release signing identity** — `CODE_SIGN_IDENTITY[sdk=macosx*]` from `Apple Development` to `Apple Distribution` (Release config `BABD889F`) - [ ] **Version bump** — `MARKETING_VERSION` = `1.0`, `CURRENT_PROJECT_VERSION` = `2` (Debug + Release) ## CI secrets (owner-held) `APPSTORE_CERTIFICATES_P12`, `APPSTORE_CERTIFICATES_P12_PASSWORD`, `ASC_API_KEY_ID`, `ASC_API_ISSUER_ID`, `ASC_API_KEY_P8` (base64 of the `.p8` — **downloadable only once**, so capture it carefully). ## App Store Connect - [ ] Register bundle ID `com.interlinedlist.macos` with App Sandbox + Hardened Runtime enabled - [ ] Create the app record (SKU `interlinedlist-macos-001`), fill metadata per `App-Dmg-Pkg-Deployment.md` §6c - [ ] Screenshots — at least 1, recommend 10 at 2560×1600, into `brand-kit/screenshots/appstore/` - [ ] Privacy Nutrition Label (§10) - [ ] Archive (confirm **Apple Distribution** signing) → Validate → Upload → select build → App Review Information → Submit ## Website — hard blockers for the App Store form These are backend/site work, deferred until this path is pursued. Neither is needed for the `.pkg` release. - [ ] **Privacy Policy** at `https://interlinedlist.com/privacy` — data collected, storage, third-party sharing (user-triggered cross-posting only), account deletion in Settings, contact, effective date - [ ] **Support page** at `https://interlinedlist.com/support` — contact, help-doc links, bug reporting - [ ] Verify both return 200 **without login** ## Demo account for App Review - [ ] A reviewer account on interlinedlist.com with subscriber access and sample content (posts, one list with rows, one document). Record the credentials in App Store Connect. - [ ] App Review notes covering the two behaviours a reviewer will otherwise flag: > *"Settings ▸ Linked Accounts opens the default browser for OAuth — return to InterlinedList when done. The 'Following' timeline scope shows a Coming Soon state (the backend feed is not yet available)."* ⚠️ Keep those notes current. Several of the open parity issues change what a reviewer sees — in particular, if the following feed lands, the second sentence becomes false and reads as a stale disclosure. ## Acceptance criteria - The `app-store` branch builds, archives, and validates with Apple Distribution signing. - The `.pkg`/`.dmg` channel on `dev`/`main` is untouched. ## Notes `work-consolidation.md` §3c. Deliberately last. | open | enhancement | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/64 | Sep 7, 2026, 12:09 PM | Sep 16, 2026, 8:02 PM |
| 63 | release: Document Sync Agent on-device validation | ## Summary The Document Sync Agent (`SyncAgent/`) is built and tested — 53 passing tests including a read-only live check. The packaging pipeline embeds it at `InterlinedList.app/Contents/Library/LoginItems/InterlinedListSync.app` and registers it as an `SMAppService.agent` via Settings ▸ Document Sync. What remains **cannot be run headless**: it needs a real Aqua session and a Developer ID signature. That makes it part of the release gate, not the feature backlog. ## What the agent is A bundled `LSUIElement` menu-bar utility that mirrors documents to a local Markdown folder (the Obsidian use case). It reads the bearer token from the shared Keychain group `$(AppIdentifierPrefix)com.interlinedlist.shared`, so it syncs with no separate sign-in. Correlation uses `xattr` plus path. ## Validation checklist - [ ] **Menu-bar GUI smoke** — status item appears, Preferences window opens and behaves. - [ ] **Full notarized `.pkg` install path**, end to end: - [ ] Install the `.pkg` on a clean machine - [ ] `SMAppService.agent` registration succeeds and is visible in System Settings ▸ Login Items - [ ] The running agent reads the bearer token from the shared Keychain group - [ ] It syncs with **no separate sign-in** - [ ] Confirm the **legacy-token → shared-group migration** path for a machine that had an older build - [ ] **Exercise the live write paths once** — create, update, delete. They share the request-building and envelope decoder that the read paths already cover, but they have never been run against production. ## Two things worth deciding while validating 1. **Per-machine settings belong on the server now.** `/help/app-settings` describes per-machine settings as exactly this case — a sync folder path is meaningful on one machine only. The agent's configuration is a natural first tenant for the Applications settings work. Not a blocker for validation, but decide whether it lands before or after the release cut. 2. **Account switching.** If multi-account switching is ever built, a running agent mirroring one account's documents to a local folder needs an explicit answer for what a switch means. Flagged in that spike; noted here so the agent's owner sees it. ## Acceptance criteria - Every box above ticked on a real machine with a notarized build. - Any defect found is filed separately rather than fixed silently inside the validation pass. ## Notes `work-consolidation.md` §3b. Blocked on the release issue producing a signed `.pkg` — sequence them together. | open | enhancement | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/63 | Sep 7, 2026, 12:08 PM | Sep 16, 2026, 8:02 PM |
| 62 | release: cut the notarized pkg/dmg - version decision, Sparkle keys, notarization credentials | ## Summary Ship the notarized `.pkg` + `.dmg`. This is orthogonal to parity and can run in parallel with every feature issue. The pipeline was audited end-to-end on 2026-09-05 and is sound; what remains is owner-held credentials, two open decisions, and the run itself. Command-level detail lives in `App-Dmg-Pkg-Deployment.md`. ## Two decisions the owner must make before anything is cut ### 1. The version number is inconsistent across three places | Where | Value | |---|---| | `MARKETING_VERSION` (build settings) | **0.1.0** | | `releases/appcast.xml` | **0.0.1** — "Version 0.0.1 Alpha", enclosure `InterlinedList-0.0.1-alpha.pkg` | | The tag step in the checklist | **v1.0.0** | Pick one. ⚠️ **The appcast enclosure filename must match what the script actually produces or Sparkle will 404** on every update check — a silent failure that only surfaces later. ### 2. The appcast URL is documented in two places `Info.plist`'s `SUFeedURL` points at `https://interlinedlist.com/appcast.xml`; the comment block inside `releases/appcast.xml` documents the feed as living at `…/downloads/apple/appcast.xml`. Harmless today, but reconcile so the served path and the polled path cannot drift. ## Already fixed — do not re-litigate `notarize-and-package.sh` derived the release version by reading `CFBundleShortVersionString` out of `App/Resources/Info.plist`, which stores the **unexpanded** `$(MARKETING_VERSION)`. Every artifact would have been named `InterlinedList-$(MARKETING_VERSION).pkg`. It now resolves the value via `xcodebuild -showBuildSettings` and hard-fails with an actionable message if it is empty or still contains `$(`. Verified. Also verified sound in the same audit: all 8 `scripts/*.sh` parse (`bash -n`), `ExportOptions.plist` is `developer-id` with automatic signing, the sync-agent embed paths and both entitlements files exist, the Sparkle path is fully wired (`SparkleController` + `UpdatesMenuCommands`, `SUFeedURL` set), and the appcast's `minimumSystemVersion` **15.0** matches `MACOSX_DEPLOYMENT_TARGET`. ## Owner-held work (secrets and credentials — not delegable) **Sparkle keys** - [ ] Generate the Ed25519 key pair: `./bin/generate_keys` — store the private key in a password manager, never commit it - [ ] Paste the public key into `App/Resources/Info.plist` → `SUPublicEDKeyString` (currently `TODO_REPLACE_WITH_ED25519_PUBLIC_KEY`) - [ ] Verify `SUFeedURL` and `SUPublicEDKeyString` resolve against the published appcast **Developer ID credentials** - [ ] `scripts/store-notarization-profile.sh` (Apple ID, Team ID `BJA9558E4B`, app-specific password) → creates the `NotarizationProfile` Keychain item - [ ] GitHub Actions secrets (`App-Dmg-Pkg-Deployment.md` §1b): `CERTIFICATES_P12`, `CERTIFICATES_P12_PASSWORD`, `CODESIGN_IDENTITY`, `INSTALLER_IDENTITY`, `NOTARIZATION_PASSWORD`. ⚠️ The `.env` `CODESIGN_IDENTITY` / `INSTALLER_IDENTITY` values are **placeholders** — replace with real Developer ID certs. ## The release run - [ ] `scripts/notarize-and-package.sh` (env per `App-Dmg-Pkg-Deployment.md` §3b) → `.pkg` + `.dmg` in `releases/`. This step also builds and embeds the Document Sync Agent. - [ ] `./bin/sign_update releases/InterlinedList-<version>.pkg`; copy the `edSignature` and byte count into `releases/appcast.xml`, replacing `TODO_REPLACE_WITH_SIGNATURE` and `length="0"` - [ ] Upload `.pkg`, `.dmg`, `.sha256` to `https://interlinedlist.com/downloads/apple/` - [ ] Publish `releases/appcast.xml` to the agreed feed URL (needs distribution infra on interlinedlist.com) - [ ] `git tag v<version> && git push origin v<version>` → triggers `release.yml` (draft GitHub release). **Owner-driven, per project convention.** - [ ] Publish the draft GitHub release ## Backend prerequisite — gates a feature that already ships client-side - [ ] Run production migrations: `npm run db:migrate:deploy`. Two are pending: `add_moderation_tables`, `add_moderation_versioning_sessions`. **Moderation ships in the client today and depends on these.** - [ ] Resolve the `GET /api/messages` unauthenticated-behaviour decision (tracked with the backend asks). ## Pre-flight - [ ] `main` is behind `dev` — catch it up before cutting. - [ ] Full E2E gate green on the commit being released. ## Acceptance criteria - One version number, used consistently by the build, the appcast, and the tag. - A notarized `.pkg` installs on a clean machine and Sparkle finds the appcast. ## Notes `work-consolidation.md` §3a. The Document Sync Agent's on-device validation is a separate issue and is part of the same ship gate. | open | enhancementneeds-decision | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/62 | Sep 7, 2026, 12:08 PM | Sep 16, 2026, 8:02 PM |
| 61 | decision: dashboard/widgets, blog, and subscription surface - G28, G29, G39 | ## Summary Three product areas where the right first step is an owner decision, not code. Each is real surface on the web; none is obviously right for a native macOS client. Filing them together so the decisions are made once. --- ## 1. Dashboard / Front wall + widgets `/help/getting-started` puts the **Dashboard** second in prominence, right after Home: > **Dashboard** (click the chart icon) shows only your own messages in a table view. It also includes Data Management (links to Exports and Settings) and your profile summary. Its tiles: Scheduled posts, Exports, Settings, Profile Information, and a **Lists tree** (of lists *and list folders*). There is also a configurable **front wall**, plus widgets — Location, Weather, markets, news, transit, transit stops, bike-share — and a full-screen **Clock** page. **⚠️ New finding, 2026-09-07: this is backend-blocked for a Bearer client, not merely a design question.** Every route is `x-auth-type: session`: ``` GET/PUT /api/user/dashboard-layout session GET/PUT /api/user/front-wall-layout session GET /api/widgets/{markets,news,transit,transit/stops,bike-share} session ``` (`GET /api/weather` and `GET /api/location` are public.) So even if the answer is "build it", the saved-layout half cannot be built until the routes accept Bearer. **The design question stands regardless:** a native app arguably wants its own arrangement rather than mirroring the web's saved layout. Note that most of the Dashboard's *content* already exists natively — Scheduled posts, Exports, Settings, and a lists tree all ship; what is missing is the aggregation and the own-messages table view. A native "Dashboard" could be assembled from existing pieces with **no new API surface at all**, which is a materially cheaper option than the layout-syncing one. **Options:** 1. **Do nothing.** The constituent features are already reachable from the sidebar. 2. **Native dashboard, no layout sync** *(cheapest useful option)* — a summary scene assembled from what already ships, plus an own-messages table. No new routes, not blocked. 3. **Full parity** including saved layouts and widgets. Blocked on the session-only routes above. --- ## 2. Blog `/help/blog` documents a Blog feature, and the web footer links `/blog`. The public API surface is only subscribe / confirm / unsubscribe (`POST /api/blog/subscribe`, `GET /api/blog/subscribe/confirm`, `GET`/`POST /api/blog/unsubscribe` — all public, all Bearer-safe). **Authoring lives entirely behind `/api/admin/blog*`**, which is admin-only and out of scope for a general client. **Options:** 1. **Do nothing.** The blog is a website; a link out is enough. 2. **Reader only** — render posts natively. ⚠️ Note there is **no public read route** in the spec (only subscribe/unsubscribe), so a native reader would need either a new public endpoint or HTML scraping. Scraping is not acceptable; confirm a read route exists before choosing this. 3. **Reader + subscribe management**, using the three public routes. Native authoring is admin-gated and almost certainly out of scope. --- ## 3. Subscription status surface The web has `/subscription` (current tier, Manage, Cancel — all via Stripe). This repo's standing decision is the opposite for native: *"billing is managed in the web app by owner decision"*, and there is no in-app-purchase surface. Meanwhile `LiveEntitlements` already reads `customerStatus` (live probe 2026-09-07 returns `"customerStatus":"subscriber"`), and `SettingsRootView`'s header comment still lists "Subscription" as planned — so the code is ambiguous about which way this went. **Options:** 1. **Do nothing** and delete the stale comment. 2. **Recommended — read-only status** in Settings ▸ Account showing the current tier, plus a "Manage subscription in your browser" button. No Stripe contact from the app, no payment UI ever native. This is what the entitlement-gating work wants anyway: when a Free user hits an Upgrade prompt, it needs somewhere to send them. 3. **Full native billing.** Reverses the standing decision and raises sandboxing and App Review questions. Not recommended. --- ## What "done" looks like for this issue - [ ] A decision recorded for each of the three, in `docs/decisions/`. - [ ] For anything chosen, a follow-up implementation issue filed and linked here. - [ ] For anything declined, the stale code comments and doc references cleaned up so a future sweep does not re-file it. ## Notes `work-consolidation.md` tracks these as **G28**, **G29**, and **G39**. The dashboard's session-only constraint is new information from the 2026-09-07 sweep and materially changes option 3's cost. | open | parityneeds-decision | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/61 | Sep 7, 2026, 12:07 PM | Sep 16, 2026, 8:02 PM |
| 60 | spike: multi-account switching is session-cookie-only under Bearer - G10 | ## Summary The web account menu ends with `Switch to ▸` — an account switcher. macOS has none, and **cannot build one today**: the entire account family is session-cookie-only. ## The blocker (re-verified 2026-09-05, still true in the 2026-09-07 spec) | Route | `x-auth-type` | Result under Bearer | |---|---|---| | `GET /api/auth/accounts` | `session` | **401** | | `POST /api/auth/switch` | `none`/session-driven | unusable without a cookie session | | `POST /api/auth/remove-account` | `session` | unusable | A Bearer-only client cannot enumerate, switch, or remove accounts. This is the same family as the other session-only routes in the backend-asks issue. ## The spike question Resolve the Bearer-vs-session constraint **before** designing anything: 1. Can the client drive a real cookie session for just these routes (sign in via `POST /api/auth/login`, hold the `interlinedlist_session` cookie, use it only here)? What does that cost in complexity and in Keychain/cookie-store handling under App Sandbox? 2. Or should a Bearer variant be requested upstream? A per-account sync token is arguably the more natural native model anyway — several tokens in the Keychain, switch by swapping which one the client sends, no server-side "current account" concept at all. Option 2 may make the whole server-side switch mechanism unnecessary for native. Establish that before writing code, because the two designs share almost nothing. Deliverable: a written recommendation in `docs/spikes/`. ## What the implementation would involve, once unblocked **Domain / Kit** - [ ] Per-account credential storage — `KeychainCredentialStore` keyed by account, not a single slot. - [ ] An account-switch operation with a clean cache reset. **App** - [ ] An account switcher in the account menu / sidebar Profile group. - [ ] ⚠️ **Cache reset on switch is the risk.** The app carries an on-disk SWR cache for Lists, Documents, Scheduled, Organizations, and Messages, plus a `LaunchPrefetchCoordinator`. Every one of those stores must be scoped per account or cleared on switch — otherwise account A's cached lists paint under account B's session, which is a data-leak-shaped bug, not a cosmetic one. Design the cache scoping **before** the switcher UI. - [ ] The Document Sync Agent reads a token from the shared Keychain group — decide what an account switch means for a running agent that is mirroring one account's documents to a local folder. This is not obvious and needs an explicit answer. **Tests** - [ ] happy — switch between two accounts; each sees only its own content - [ ] invalid — switch to an account whose credential has been revoked - [ ] upstream-failure — switch fails mid-way → the app stays signed in as the original account, not signed out - [ ] boundary — one account (switcher hidden); removing the currently-active account ## Acceptance criteria - The spike's recommendation is written down. - If built: no cached artifact from one account is ever visible under another. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G10** / spike S4. Blocked on the session-only route family — see the backend-asks issue. Size M after the spike. | open | parityblockedspike | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/60 | Sep 7, 2026, 12:07 PM | Sep 16, 2026, 8:04 PM |
| 59 | spike: can a notarized Developer-ID pkg receive APNs pushes - G9 | ## Summary The push routes are confirmed live and the per-event push switchboard already ships. What is not known is whether a **sandboxed, notarized, Developer-ID `.pkg`** app — which is the current distribution model, not Mac App Store — can receive APNs pushes at all, and what provisioning that requires. **Spike first. Do not start the client work until the spike answers the question.** ## What is already settled - `POST /api/push/register` and `DELETE /api/push/unregister` exist in the live spec. The old "is unregister POST or DELETE?" question is **settled: DELETE**. - Per-event push switches ship as Settings ▸ Notifications (G18, PR #25). Each event's `channels.push` flag is the switchboard this feature consumes — and the channels genuinely vary per event, so not every event can push. - Tray polling + local notifications already work and are **not** to be replaced. Real pushes **augment** polling. ## The spike question > Can an app distributed as a notarized Developer-ID `.pkg` (App Sandbox + Hardened Runtime, bundle `com.interlinedlist.macos`, team `BJA9558E4B`) register for and receive APNs remote notifications? If yes, exactly what is required — an APNs key or certificate, an entitlement (`aps-environment`), a provisioning profile embedded in the app, a specific signing flow? Deliverable: a written answer in `docs/spikes/` with the concrete provisioning steps, or a clear "not possible outside the App Store" with the evidence. Either outcome is a good outcome — the second one saves the whole implementation. Relevant context if the answer is nuanced: the App Store path already exists as a separate future branch, so "push works on the App Store build only" is an acceptable finding that would simply move this feature there. ## Deep-link routing depends on a backend field Routing a push to the right screen wants the notification `routePath` field, which does not exist yet (tracked with the other backend asks). Without it the client can still route using its existing typed `NotificationTarget` projection — confirm that projection covers every push-capable event before relying on it. ## Division of labor — only after the spike says yes **Spike (do this alone, first)** - [ ] Answer the provisioning question; write it up in `docs/spikes/`. - [ ] If provisioning is required, list exactly which secrets/certs the owner must create (this repo's convention is that the owner holds all signing material). **Kit** - [ ] `Push.register(token:)` / `Push.unregister(...)` + DTOs + contract tests. **Domain** - [ ] A push-registration service: register on launch and on sign-in, unregister on sign-out. Registration must be idempotent — a token that has not changed should not re-register on every launch. - [ ] Respect the per-event `channels.push` preferences from Settings ▸ Notifications. **App** - [ ] Request notification authorization at a sensible moment (not at first launch, before the user knows what the app is). - [ ] Route an opened push to the right screen, reusing the existing `NotificationTarget` projection. - [ ] Pushes must not double-notify alongside tray polling — dedupe on notification id. **Tests** - [ ] happy — token registers on sign-in, unregisters on sign-out - [ ] invalid — user denies notification permission → no registration attempted, no error surfaced - [ ] upstream-failure — register returns non-2xx → retried on next launch, never blocks sign-in - [ ] boundary — token rotation mid-session; the same notification arriving by both push and poll appears once ## Acceptance criteria - The spike's answer is written down, whichever way it goes. - If built: signing out stops pushes to that device. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G9** / spike S2. Size M after the spike. | open | parityspike | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/59 | Sep 7, 2026, 12:06 PM | Sep 16, 2026, 8:04 PM |
| 58 | blocked: backend asks - following feed, session-only routes, article_series 502, shape confirmations | ## Summary A single place to record and act on the remaining backend dependencies. Each carries a paste-ready prompt for the InterlinedList API session (base URL `https://interlinedlist.com`). None of these can be closed from the client. **Nothing here blocks the rest of the parity work** — every other open parity issue is client-buildable today. This issue exists so the asks are tracked rather than rediscovered on each sweep. --- ## 1. Following / home feed — the one high-impact blocker **Re-verified still broken 2026-09-05.** An authenticated `GET /api/messages?limit=50` and `GET /api/messages?limit=50&scope=following` return the identical 50 messages from the identical author set, a month after the first report. `MessagesService.timeline` short-circuits `.following` to an empty "coming soon" page (`MessagesService.swift:364`); the All/Mine/Following picker is fully wired and flips one branch the moment the feed exists. This also blocks the `Following Only` option in the viewing-preference work. > **PROMPT:** Add a followed-accounts timeline feed. Preferred: extend `GET /api/messages` with `?scope=following` (or add `GET /api/feed/following`), returning only messages authored by accounts the caller follows, using the **same paginated envelope** as `GET /api/messages` (same `limit`/`offset`/`hasMore` shape). Bearer auth. Document it. Note: as of 2026-09-05 the live server silently ignores `scope`/`feed`/`following`/`filter` on `GET /api/messages`, so this needs a real implementation, not just docs. The macOS client already has the UI wired and flips one branch to consume it. --- ## 2. Session-only routes lock out every Bearer client The live OpenAPI spec declares 45 operations as `x-auth-type: session` — *"requires the web session cookie only; a Bearer token is not accepted"*. Setting aside admin, cron, Stripe, and architecture-aggregates, these are the ones a native app legitimately needs: | Route | What it costs the native client | |---|---| | `POST /api/lists/invite/{token}` | Cannot **accept** a list email invite | | `POST /api/documents/invite/{token}` | Cannot **accept** a document email invite | | `POST /api/lists/shared/{token}` | Cannot **claim** an editor/admin list share link | | `POST /api/documents/shared/{token}` | Cannot **claim** an editor/admin document share link | | `POST /api/auth/send-verification-email` | Cannot offer **Resend verification email** | | `GET /api/user/engagement` | No engagement stats (confirmed **401** under Bearer, 2026-09-07) | | `GET`/`PUT /api/user/dashboard-layout` | Dashboard is unbuildable | | `GET`/`PUT /api/user/front-wall-layout` | Front wall is unbuildable | | `GET /api/widgets/*`, and by extension the widget surface | Widgets are unbuildable | | `GET /api/auth/accounts`, `POST /api/auth/switch`, `POST /api/auth/remove-account` | Multi-account switching is unbuildable | The *read* halves (`GET /api/lists/invite/{token}` etc.) are public, so the client can show an invite landing page and then hand the user to the browser to accept — a poor but honest experience. Note the spec's own prose is stale here: its description lists *"exports, organizations, LinkedIn targets, GitHub helpers, message dig, message edit"* as session-only, but the per-operation `x-auth-type` values show all of those as `sync-token`. Worth flagging in the same ask. > **PROMPT:** The macOS client authenticates with a Bearer sync token (`POST /api/auth/sync-token`). Several operations are declared `x-auth-type: session` and reject Bearer, which makes them unreachable for native and CLI clients. Please make the following Bearer-reachable, or document a supported alternative: (1) the **claim/accept** halves of the invite and share-link flows — `POST /api/lists/invite/{token}`, `POST /api/documents/invite/{token}`, `POST /api/lists/shared/{token}`, `POST /api/documents/shared/{token}`. Accepting an invite is documented as always free, so there is no entitlement reason for the restriction. (2) `POST /api/auth/send-verification-email`, so a native app can offer the documented "Resend verification email" action. (3) `GET /api/user/engagement`. Separately: the OpenAPI `info.description` lists exports, organizations, LinkedIn targets, GitHub helpers, message dig, and message edit as session-only, but their per-operation `x-auth-type` is `sync-token` — please reconcile the prose with the operation values. --- ## 3. `article_series` returns 502 `provider_error` on every attempt **Found 2026-09-05, re-confirmed 2026-09-06.** `POST /api/ai/suggest {"feature":"article_series"}` fails with `{"error":"The AI provider rejected the request.","code":"provider_error"}` on three separate attempts across two days, with different briefs, all well over the ten-word minimum. The same account, key, and model succeed for `writing_assist`, `message_series`, `powered_template`, and `powered_document` — so it is not entitlement, quota, or input length. The macOS client blames the provider rather than the user, so it degrades honestly, but **Article Series cannot work for any client until this is fixed.** > **PROMPT:** `POST /api/ai/suggest` with `{"feature":"article_series","input":"<a multi-sentence brief>"}` returns `502 {"error":"The AI provider rejected the request.","code":"provider_error"}` every time, for a subscriber whose other AI features (`writing_assist`, `message_series`, `powered_template`, `powered_document`) all succeed on the same request path. Reproduced three times across 2026-09-05 and 2026-09-06 with different briefs. Please check the `article_series` prompt/schema construction and what the provider is actually rejecting — likely a malformed tool/response schema for that feature specifically — and confirm the artifact shape it should return (the other features return `{artifact:{kind,…}}`). --- ## 4. GitHub issue shapes still cannot be exercised end-to-end The test account **is** GitHub-linked now (`GET /api/github/repos` → 200, up from the old 400 "not linked"), but the repo list comes back **empty** and `GET /api/github/orgs` returns `[]` — so issue create/update/comment request and response envelopes remain unverified. The **routes** are settled (PR #24 repointed the client at the flat three-segment paths); only the payloads are unknown. **This one may not need the backend at all** — granting the `.env` test account access to a single repository would unblock it locally. Try that first. --- ## 5. Smaller confirmations and additive niceties Each is worked around today; these make the client correct or cheaper. Additive fields are always safe — the client decodes by name and ignores unknowns. - **Auth decision on `GET /api/messages`** (returns 200 with public content and no `Authorization` header). No client change either way; needs a documented decision. *(P1-F)* - **Follow action returns `followedBy`** — the client decodes `{follow:{status}}` and needs a second `GET /api/follow/{userId}/status` call. A `relationship` block on the follow/unfollow/approve/reject responses removes a round-trip. *(P2-B)* - **Typed notification kinds + `routePath`** — deep-linking works via a client-side typed projection; a stable `routePath` collapses it to a plain URL and unblocks APNs push routing. *(P2-C)* - **Markdown export** — `/api/exports/*` is CSV-only, so the client renders Markdown itself, costing N+1 refetches for bulk export. *(P2-F)* - **Schema DSL `select`/`markdown` token spec** — `Field:select(a|b|c)` is a **client convention, still API-unconfirmed**. This blocks the per-field validation work. *(P2-G)* - **Document version / ETag** for sync conflict detection. *(P3-A)* - **`folderId` on sync-response documents**, including preserved copies and deleted documents. *(P3-B)* - **GitHub-backed list refresh metadata + `githubSource` on create** — `lastRefreshedAt`, `refreshStatus`, `refreshError`; blocks part of the GitHub-backed-lists work. *(P3-C)* - **`RateLimit-*` headers universally** — currently only on `POST /api/messages` and `POST /api/documents/sync`. *(P3-E)* - **List clone-with-rows** — "save to my lists" copies title/description/schema but **no rows**, because no clone endpoint exists. *(P3-G)* Full paste-ready prompts for each live in `work-consolidation.md` §2c. --- ## Acceptance criteria - Each numbered item is either resolved upstream or explicitly deferred with a reason. - When one lands, the client-side issue it blocks is unblocked and linked here. ## Notes Consolidates `work-consolidation.md` §2a and §2c. Re-verify before acting — three items previously filed as backend blockers (`P1-H2`, `P2-I`, `P3-D`) turned out to be **client-side** errors and were fixed in PR #24/#25. Probe first, ask second. --- ## Additions from the 2026-09-15 sweep Three asks were resolved **without** a backend conversation, and two new ones are added. Recording both halves, because "we thought this was blocked and it was not" is as useful to the next sweep as the remaining blocks. ### Resolved by probe — remove from the blocked list | Was blocked on | Answer | |---|---| | **The notification tray showing read history** (#80) | `scope=all` already exists, returns read + unread, and honours `limit`. No ask needed. | | **A DM notification producer** (#77) | `/api/user/notification-preferences` lists `direct_message` as a first-class event, so the server emits them. A mapping change, not an ask. | | **The list-schema DSL encoding** (`P2-G`, blocking #50 item 1) | Confirmed. It is not DSL syntax — `validationRules {min,max,minLength,maxLength,pattern,options}`, `helpText`, `placeholder`, `isRequired`, `isVisible`, `visibilityCondition` are first-class fields. Captured in `docs/spikes/list-schema-wire-shapes.md`. | ### New ask 1 — clearing a profile location (filed as #91) A profile location can be **set** through `PATCH /api/user/update` and **cleared through nothing**. `null`, `""`, `"null"`, `false` and out-of-range values are all `400`; `clearLocation`, `location:null` and `cleared:true` all return `200` and change nothing; `DELETE /api/user/location` is `404`. These coordinates are published on a public profile, so a field that can be set and not unset is a privacy defect. **This blocks #57's setter entirely** — shipping set-without-clear would make macOS a way to publish an approximate home location that the user cannot take back. **The ask:** accept `null` on `latitude`/`longitude` and treat the pair as cleared, *or* add `DELETE /api/user/location`, *or* name the mechanism the web uses if one exists. ⚠️ The probe left a location set on the `.env` recon account (47.6062 / −122.3321) that it could not undo. That is the bug reproduced; it needs clearing out of band. ### New ask 2 — a populated DM `items[]` (for #76) `GET /api/dm/conversations` is still empty on the recon account, so the populated shape has never been observed and the decoder stays deliberately permissive. **The ask is small:** one real `items[]` row pasted into #76. The alternative — a second recon account — is worth having anyway, since list sharing, org membership and follow requests all have "the other person" paths that are untestable end to end for the same reason. ### Shape confirmations this sweep supplied Several of this issue's "shape confirmations" no longer need asking; the captures are in `docs/spikes/list-schema-wire-shapes.md` and in the contract tests added by PRs #87, #93 and #96 — `GET /api/lists/{id}`, `GET /api/lists/{id}/schema`, `PUT /api/lists/{id}/schema`, `GET /api/users/{u}/lists/{id}`, `GET /api/user/identities`, and the notification row shapes. | open | parityblocked | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/58 | Sep 7, 2026, 12:06 PM | Sep 16, 2026, 8:06 PM |
| 57 | feat(settings): profile location - show, set and clear latitude/longitude - G36 | ## Summary The web publishes an optional profile location (latitude / longitude) that powers its Location and Weather widgets. `UserDTO` decodes `latitude` and `longitude` (`UserDTO.swift:92–93`), the native app neither shows nor sets them, and `UpdateUserRequest` has no fields for them — so the value is **invisible and unclearable from macOS**. ## What the web offers (`/help/settings` ▸ Profile location) > **Latitude / Longitude**: Optional location for your profile, used by the Weather widget and similar location-aware features Plus a set-from-current-location action and a clear action, under a Permissions section that requests browser geolocation. ## Privacy — read this before designing the UI This publishes an approximate home location on a **public profile**. Treat it accordingly: - **Opt-in only.** Never prompt at launch, never as part of onboarding, never bundled into another save. - The pane must state plainly, in the pane itself, that the value is publicly visible on the profile. - **Clear must always work**, even if setting is unavailable — a user who set this on the web must be able to remove it from the Mac. - If Core Location is used for "set from current location", request `when-in-use` at the moment the button is pressed and never on a background path. Handle denial by leaving manual entry available, not by nagging. ## Unknown to probe first `UserDTO` carries a `cleared: Bool?` flag (`UserDTO.swift:95`) that hints at a server convention for *clearing* the value — sending `null` may not be how it is done. Live probe on the test account returns `"cleared":true` with `latitude:null, longitude:null`, which is consistent with "cleared" being the explicit signal rather than a null coordinate pair. **Set a location on the web, then clear it, and watch what the web sends.** Do not guess: a wrong clear payload could leave a location published that the user believes they removed. That is the failure mode that matters here. ## Division of labor **Kit** - [ ] Add `latitude` / `longitude` (and whatever the clear convention turns out to be) to `UpdateUserRequest`. - [ ] Contract test covering set **and** clear, asserting a cleared profile really reads back as cleared. **Domain** - [ ] Location fields on the profile-settings model, with clear modelled as its own operation rather than "write nil" — so the intent is explicit at every layer. **App** - [ ] A Profile location section in Settings: current value, manual lat/long entry, **Set from current location**, **Clear**, and the public-visibility notice. - [ ] Core Location permission requested only on the button press. - [ ] ⚠️ The App target is sandboxed — confirm the location entitlement is present and that adding it does not disturb the existing notarization/hardened-runtime setup. If it does, ship manual entry + clear first and treat "set from current location" as a follow-up; manual entry alone already closes the invisible-and-unclearable problem. **Tests** - [ ] happy — set a coordinate pair, read it back, see it on the profile - [ ] invalid — out-of-range latitude/longitude rejected client-side - [ ] upstream-failure — a failed clear leaves the UI showing the location as still set, never as cleared - [ ] boundary — clearing an already-cleared profile; a profile with a location set on the web and never touched on macOS ## Acceptance criteria - A location set on the web is visible and clearable from the Mac. - No location is ever published without an explicit user action. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G36**. Size S–M. Low priority relative to the rest of the account cluster, but the "unclearable from macOS" part is a genuine privacy wart. --- ## Probe result (2026-09-15) — re-scoped, and the setter is deliberately **not** shipping The issue was explicit: *"Set a location on the web, then clear it, and watch what the web sends. Do not guess: a wrong clear payload could leave a location published that the user believes they removed. That is the failure mode that matters here."* Probed. **There is no clear path at all**, and the `cleared`-flag inference in this issue is disproved. Full evidence in **#91**; the short version: - `PATCH /api/user/update` is the only user-mutation route (`OPTIONS /api/user` → `GET, HEAD, OPTIONS`; no `/api/user/location`). - Setting works: `{"latitude":47.6062,"longitude":-122.3321}` → `200`. - Every unset form — `null`, `""`, `"null"`, `false`, out-of-range — is **`400 latitude must be a number between -90 and 90`**. - Every speculative clear key (`clearLocation`, `location:null`, `cleared:true`) returns **`200` and changes nothing**, which is worse than a rejection. - `DELETE /api/user/location` → `404`. **On `cleared`:** this issue reasoned that `"cleared":true` alongside null coordinates was *"consistent with 'cleared' being the explicit signal"*. It was correlation, not mechanism — setting a location leaves `cleared` at `true`, and writing `cleared:true` clears nothing. In the live OpenAPI it sits among `spamScore`, `spamReasons`, `signupIp` — a moderation field that happens to share a name with what a clear flag would be called. ### Why the setter is not shipping This issue calls the situation *"a genuine privacy wart"*, and it is right — but the wart is **unclearable**, not **invisible**. Shipping "set" without "clear" would make macOS a way to publish an approximate home location on a public profile that the user cannot then remove **from anywhere**. That is strictly worse than today. Its own acceptance criterion says so: *"Clear must always work, even if setting is unavailable."* Clear cannot work. So setting does not ship either. ### Re-scoped macOS work **In scope now — the read-only half**, folded into the Profile settings pane (#46) rather than a standalone PR, since it is the same pane: - Show the coordinates when the account has them, with the plain statement that they are publicly visible. - Say honestly that removing them is not currently possible, and link to #91 rather than offering a control that cannot work. This closes the *"invisible"* half of the complaint — a location set elsewhere becomes visible on the Mac — without widening the *"unclearable"* half. **Blocked on #91:** manual lat/long entry, Set from current location (and with it the Core Location entitlement question), and Clear. They land together, because clear is the one that makes the others safe. ### ⚠️ The test account now has a location it cannot lose The probe set 47.6062 / −122.3321 on the `.env` contract-test account and **could not undo it**. That is the bug reproduced, not carelessness — but it does need clearing out of band. Noted on #91. | open | enhancementparityblocked | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/57 | Sep 7, 2026, 12:06 PM | Sep 16, 2026, 8:06 PM |
| 56 | feat(settings): finish the Applications pane - main workstation, rename, remove, copy to shared | ## Summary The Applications settings cluster shipped in PR #25 (+ #30) covering synced settings and the device registry. `/help/app-settings` documents six user actions in that section; **verify which of them the shipped pane actually offers and build the rest.** This issue is deliberately a verify-then-build, because the pane exists and some of these may already be present. ## The documented actions (`/help/app-settings`, re-read 2026-09-07) | Action | Route | In `DevicesView` today? | |---|---|---| | **Set as main workstation** | `PATCH /api/user/app-settings/{appKey}/devices/{deviceId}` | verify | | **Rename a machine** | `PATCH …/devices/{deviceId}` | verify | | **Remove a machine** | `DELETE …/devices/{deviceId}` | verify | | **View settings** (shared + per-machine, with last-updated and size) | `GET …/{appKey}`, `GET …/devices/{deviceId}/settings` | verify | | **Copy a machine's settings to shared** | `PUT …/{appKey}` from the device payload | verify | | **Delete shared settings** | `DELETE /api/user/app-settings/{appKey}` | verify | ## The rules that are easy to get wrong - **The first machine registered automatically becomes the main workstation**, and only one machine holds the role at a time. Promoting a machine demotes the previous holder — the UI must reflect that, not just set a flag. - **Removing the main workstation auto-promotes another machine** (the most recently used), server-side. After a remove, refetch rather than assuming which machine now holds the role. - **Removing a machine deletes its per-machine settings** but leaves shared settings alone. Say that in the confirmation. - **Copy to shared replaces** the shared settings wholesale — it is not a merge. Confirm destructively. - **A new machine's first sign-in seeds from the main workstation's settings**, then keeps its own per-machine settings thereafter. - This section is **free** — no subscription needed. Do not gate it. ## Standing constraints from the shipped work — do not regress - `appSettingsKey` is `"interlinedlist-macos"` (`AppEnvironment.appSettingsKey`) and **must stay stable**. Changing it orphans every stored setting. There is no registration mechanism; the segment is a free-form namespace (proven by probe: an invented key returns `200 {"devices":[]}`). - **404 is the ordinary first-run state**, not a failure. An app key with nothing stored 404s, and `bootstrap` returns `404 {"source":"none"}` for an unseen device. PR #30 mapped both to empty — keep it that way. - The **populated** payload shapes are still unverified because nothing was stored at the time. The DTOs are deliberately tolerant. **Store real settings from the Mac, then re-probe and tighten** — that is the main verification value in this issue. ## This is also the sanctioned home for the Sync Agent's config `/help/app-settings` frames per-machine settings as exactly the case the Document Sync Agent needs (a sync folder path is meaningful on one machine only). Migrating the agent's `UserDefaults` state here is in scope if the pane's read/write half is solid — otherwise split it out. ## Division of labor **Verify first (no code)** - [ ] Walk `App/Features/Settings/DevicesView.swift` and `DevicesViewModel.swift` against the six-action table and tick off what exists. - [ ] Write real settings from the Mac, then re-probe every `GET` and record the populated shapes in `docs/spikes/`. **Kit** — only the routes the verify pass shows are missing, plus tightened DTOs once the shapes are known. **Domain** — the promote/demote and remove-then-refetch semantics; copy-to-shared as an explicit replace. **App** — the missing actions in `DevicesView`, each with a confirmation that states its actual consequence. **Tests** - [ ] happy — rename, promote, remove, copy-to-shared each round-trip - [ ] invalid — promote a device that no longer exists - [ ] upstream-failure — remove succeeds but the refetch fails → the row disappears and the main-workstation badge shows unknown rather than a stale value - [ ] boundary — exactly one registered device (removing it); first-run 404s still map to empty ## Acceptance criteria - Every action `/help/app-settings` documents is available in Settings ▸ Applications. - The populated payload shapes are recorded, and the DTOs are as tight as the evidence allows. - `appSettingsKey` is unchanged. - Full E2E gate green. ## Notes Follows **G17** (`work-consolidation.md`), shipped in PR #25/#30. Size S–M depending on what the verify pass finds. | closed | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/56 | Sep 7, 2026, 12:05 PM | Sep 16, 2026, 8:03 PM |
| 55 | feat(scheduled): edit a queued post content and destinations, not just its time | ## Summary macOS can cancel and reschedule a queued post. The web can edit its **content** and its **destinations** too. And the schedule dialog itself differs: the web picks per-network destinations at schedule time, the Mac does not. ## What the web does (`/help/messages` ▸ Scheduled posts) Scheduling flow: > Open the advanced options (gear icon) … Click the **calendar icon** … Pick a date and time in the future. **The dialog also shows a checkbox for each connected cross-post account (Mastodon, Bluesky, LinkedIn, X/Twitter) so you can choose which networks receive the post when it goes live.** … A note below the compose button shows the scheduled date and time. To cancel scheduling and post immediately instead, click the displayed date. Managing them: > Each scheduled post shows its content, scheduled time, **and the destinations selected**. Click the edit icon to change **the time, the message content, or which networks will receive the post**. If LinkedIn is selected, **the same LinkedIn destination picker from the compose box appears here**. To cancel a scheduled post, open it and delete it. ## What macOS ships today `ScheduledPostsViewModel` (`App/Features/Scheduled/ScheduledPostsViewModel.swift`) exposes: - `load()` - `cancel(post:)` - `reschedule(post:to:)` No content edit. No destination edit. No destination display. The composer (`ComposerWindowView.swift:298` `scheduleSection`, `:161` `crossPostSection`) has schedule and cross-post as two independent sections rather than the web's combined schedule-with-destinations dialog. Verify whether cross-post selections made in the composer are actually carried onto a *scheduled* post — if they are dropped, that is a second defect hiding inside this one and should be called out in the PR. ## Division of labor **Kit** - [ ] Confirm the scheduled-post update route and the fields it accepts (content, `scheduledAt`, cross-post flags). Message edit is `PATCH /api/messages/{id}` since PR #24 — confirm a *scheduled* message edits the same way and that cross-post flags are mutable post-scheduling. **Probe before building.** **Domain** - [ ] Extend the scheduled-post model to carry its selected destinations. - [ ] A single `updateScheduled(...)` covering time, content, and destinations, replacing the narrow `reschedule`. - [ ] Reuse the existing LinkedIn posting-target projection so the destination picker is the same code in both places. **App** - [ ] Composer: fold the per-network checkboxes into the schedule dialog, matching the web, and keep the "click the date to post now instead" affordance. - [ ] `ScheduledPostsRootView`: show each post's destinations; an edit sheet covering time, content, and destinations, including the LinkedIn destination picker. - [ ] Keep cancel/delete as-is. **Tests** - [ ] happy — edit content, time, and destinations on a queued post; all three persist - [ ] invalid — reschedule into the past → rejected client-side with a clear message - [ ] upstream-failure — update rejected → the sheet keeps the user's edits and surfaces the error - [ ] boundary — a post scheduled with zero destinations; one with every network selected; editing a post seconds before it fires ## Acceptance criteria - Everything the web lets you change about a queued post can be changed on the Mac. - A scheduled post's destinations are visible without opening an editor. - Full E2E gate green. ## Notes **New finding from the 2026-09-07 parity sweep.** Size S–M. Touches the same LinkedIn destination model as the Integrations and Organizations issues — sequence it after at least one of them so the projection exists. | closed | enhancementparityblocked | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/55 | Sep 7, 2026, 12:05 PM | Sep 15, 2026, 11:27 AM |
| 54 | feat(orgs): delete, leave, suspend, My Organizations, and org LinkedIn pages - G25 | ## Summary The client can create and read organizations and manage members' roles, but cannot **delete** an org, **leave** one, **suspend** a member, or see **My Organizations** as its own surface. The whole organization-LinkedIn feature — a real, documented product capability — is absent. ## Route gaps (all live, Bearer-reachable) | Route | What it unlocks | |---|---| | `DELETE /api/organizations/{id}` | Delete an organization (owner only) | | `GET /api/user/organizations` | **My Organizations** — orgs you belong to, with your role | | `POST /api/user/organizations` | (probe: likely the join path — confirm before use) | | `GET /api/organizations/{id}/linkedin/status` | Org LinkedIn credential status | | `POST /api/organizations/{id}/linkedin/sync-pages` | Pull the org's LinkedIn company pages | | `PUT /api/organizations/{id}/linkedin/assignments` | Assign company pages to members | | `DELETE /api/organizations/{id}/linkedin/credential` | Disconnect the org credential | Live shape for `GET /api/user/organizations` (2026-09-07) — richer than the browse list, carrying the caller's role and membership: ```json {"organizations":[{"id":"fb29220f-…","name":"Bikey Life","slug":"bikey-life", "description":"…","avatar":null,"isPublic":true,"isSystem":false, "settings":null,"createdAt":"…","updatedAt":"…","deletedAt":null, "role":"member","joinedAt":"…","userRole":"member","memberCount":3}]} ``` Note `isSystem` — that is how "The Public" is flagged, and it must not offer a Leave action. ## Behaviour gaps (`/help/organizations`) - **Suspend a member.** *"Change an existing member's role, **suspend their access**, or remove them using the controls in the members table."* macOS `OrgMembersViewModel` has `changeRole`, `addMember`, `removeMember`, `lookupUser`, `addMemberByHandle` — no suspend. - **Leave an organization.** *"Use the **Leave** button next to an organization in the Organizations list or in your My Organizations view."* - **Last-owner protection.** *"The last remaining owner cannot be demoted or removed"* and cannot leave until another owner exists. Enforce client-side so the user gets an immediate, specific message rather than a server rejection. - **"The Public"** is a system org everyone auto-joins and **nobody can leave**. - Creating an org is subscriber-gated (server returns **403** otherwise); joining is free. Covered by the entitlement issue — consume the gate here, do not re-implement it. ## Org LinkedIn, in the web's words > An owner or admin connects the org's LinkedIn credential (requesting LinkedIn's organization-admin scope). The company pages LinkedIn returns are stored on the organization. Owners and admins **assign** specific company pages to specific members. When an assigned member cross-posts to LinkedIn, it can publish to their assigned company page using the shared org credential. Members without an assignment fall back to their personal LinkedIn identity. > > For a member who has an assignment, the assigned org page also becomes their **default** LinkedIn destination … If an owner or admin later disconnects the org's credential, every assigned member falls back to their personal LinkedIn. That last paragraph is the part with teeth: an org assignment **changes where a member's ordinary LinkedIn cross-post goes**. The composer's "Posting as …" readiness line (shipped as G11a) must reflect the assigned page, or a member will believe they are posting to their own profile while publishing to a company page. ⚠️ **Re-confirm `orgScopesEnabled` on this tenant** before building — the org LinkedIn routes were previously recorded as not deployed and only appeared in the 2026-09-05 spec. ## Division of labor **Kit** - [ ] `Organizations.delete(id:)`, `User.organizations()`, and the four org-LinkedIn routes + DTOs - [ ] Confirm what `POST /api/user/organizations` does before building against it - [ ] Contract tests **Domain** - [ ] `OrganizationsService`: delete, leave, suspend-member, my-organizations - [ ] Encode last-owner protection and the `isSystem` no-leave rule as client-side preconditions with their own error cases - [ ] Org LinkedIn: status, sync-pages, assignments, disconnect - [ ] Feed the member's effective LinkedIn destination into the composer's existing posting-target projection **App** - [ ] A **My Organizations** surface showing role, joined date, and member count, with per-row Leave - [ ] Owner-only Delete, with a confirmation that names the org and states it is not reversible - [ ] Suspend / unsuspend in the members table - [ ] An org LinkedIn section for owners and admins: connect, sync pages, assign pages to members, disconnect (disconnect is destructive for every assigned member — say so) - [ ] Composer: when the signed-in member has an org page assignment, "Posting as …" names the **company page** **Tests** - [ ] happy — leave a non-system org; delete an owned org; suspend and restore a member; assign a page - [ ] invalid — last owner tries to leave / be demoted; anyone tries to leave "The Public" - [ ] upstream-failure — sync-pages fails → assignments UI stays usable with the stale page list, error surfaced - [ ] boundary — an org with exactly one owner and one member; a member with and without a page assignment ## Acceptance criteria - Every organization action the web offers is available on the Mac, with last-owner and system-org rules enforced before the network call. - A member with an org page assignment can tell where their LinkedIn cross-post is going. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G25** (formerly G11b, no longer upstream-blocked). Size M. The personal-scope LinkedIn work (`PUT /api/linkedin/posting-targets`, `POST /api/linkedin/sync-pages`) lives in the Integrations issue — coordinate so the LinkedIn domain model is written once. | closed | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/54 | Sep 7, 2026, 12:04 PM | Sep 13, 2026, 10:40 PM |
| 53 | feat(dm): conversations inbox, single-message fetch, and photo attachments - G22 | ## Summary Direct Messages shipped (folders, threads, read-state, unread badge) without three live routes. One of them makes the DM composer advertise something it cannot do. | Route | Live | What it unlocks | |---|---|---| | `GET /api/dm/conversations` | ✅ 200 (probed 2026-09-07: `{"items":[],"nextCursor":null}`) | One row per conversation grouped by `pairKey`, newest first — **the natural inbox**, versus today's folder listing that the client collapses into conversation rows itself | | `GET /api/dm/{id}` | exists | Fetch a single message (deep-link target) | | `POST /api/dm/images/upload` | exists | **DM image attachments** | ## The image gap is the user-visible one `/help/direct-messages`: *"You can attach photos to a direct message, up to **8 per message**. Photos are resized automatically. You'll need a **verified email address** to send images."* The macOS DM composer surfaces attachments but has no upload route behind them. Wire the same `ImagePrep` path the message composer uses, with the server-driven size ceilings from `ContentLimits` (shipped as G14) rather than fresh constants. ## The conversations route is a correctness improvement, not just a convenience Today `DirectMessagesListViewModel` loads a **folder** page and groups it into conversation rows client-side (`DirectMessagesListViewModel.swift:10` — *"the folder listing is a flat, newest-first list"*). That means a conversation whose most recent message falls off the end of the fetched page is invisible, and the grouping is only as complete as the page. `GET /api/dm/conversations` is server-side grouping with its own cursor and does not have that failure mode. ⚠️ **It returned empty on the test account**, so the populated `items[]` shape is unverified. Send a DM between two accounts that follow each other and record the real payload before tightening the decoder — this API has a documented history of envelope drift (`POST /api/messages` wrapping under `data`). ## Constraints to honour - **Mutual-follow only.** *"You can only message people you follow each other."* The recipient picker must be sourced from mutual followers, and the empty state must say why: *"If the list is empty, it means no one who follows you also follows you back yet."* - **Markdown, up to 10,000 characters.** Confirm the native composer's limit matches; it is far larger than a message. - **Opening a conversation marks it read** automatically. - Inbox / Sent / **Deleted** with delete + restore already ship (`DirectMessagesListViewModel.swift:176`) — leave them alone. - Email verification gates image sending — reuse the gate from the email-verification issue rather than a second check. ## Division of labor **Kit** - [ ] `DirectMessages.conversations(cursor:)` → `GET /api/dm/conversations` - [ ] `DirectMessages.message(id:)` → `GET /api/dm/{id}` - [ ] `DirectMessages.uploadImage(...)` → `POST /api/dm/images/upload` - [ ] DTOs + contract tests, against a **populated** conversation list **Domain** - [ ] Switch the inbox to the server-grouped conversations feed; keep the folder listing for Sent/Deleted, which the conversations route does not replace. - [ ] DM image upload through the shared `ImagePrep` + `ContentLimits` path; enforce the 8-image cap. - [ ] Single-message fetch for deep links. **App** - [ ] `DirectMessagesRootView` inbox paints from conversations with its own cursor pagination. - [ ] Attachments in `NewMessageSheet` / `DMThreadView` actually upload, gated on email verification with the shared explanation. - [ ] Recipient picker sourced from mutual followers with the documented empty state. **Tests** - [ ] happy — conversations paginate; an image attaches and appears in the thread - [ ] invalid — a 9th image is refused client-side; an unverified account cannot attach - [ ] upstream-failure — upload fails → the message still sends as text, with the failure surfaced (do not lose the draft) - [ ] boundary — empty conversation list; a 10,000-character body; a conversation whose last message predates the first page ## Acceptance criteria - The DM inbox no longer depends on client-side grouping of a folder page. - Attaching a photo to a DM works, or is clearly explained why it cannot. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G22**. Size S–M. | closed | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/53 | Sep 7, 2026, 12:04 PM | Sep 13, 2026, 10:40 PM |
| 52 | feat(documents): single-call tree, public docs by user, create-in-folder, move to folder - G24 | ## Summary Four Documents routes the client does not build, plus two authoring affordances the web has and macOS lacks. ## Routes | Route | Auth | What it unlocks | |---|---|---| | `GET /api/documents/tree` | sync-token | The **whole sidebar in one call** — today macOS assembles it from several | | `GET /api/users/{username}/documents` | public | **Public documents by user** — the profile page has no documents tab | | `POST /api/documents/folders/{id}/documents` | sync-token, subscriber | Create a document **directly in a folder** | | `GET /api/documents/invite/{token}` | public | Invite landing (the accept half is session-only — see the caveat) | ## Live shapes (2026-09-07, Bearer) `GET /api/documents/tree`: ```json {"folders":[{"id":"2b776197-…","name":"One-Folder","parentId":null, "documents":[{"id":"effbcb05-…","title":"a-single-doc", "relativePath":"a-single-doc.md","isPublic":false}]}, {"id":"b6a8f347-…","name":"_templates","parentId":null, "documents":[{"id":"c283ee0b-…","title":"Bespoke_Template!!!","…":"…"}, {"id":"c20f9ba6-…","title":"Recipe","…":"…"}]}, {"id":"3775f95a-…","name":"emptiness","parentId":null,"documents":[]}], "rootDocuments":[{"id":"60668d66-…","title":"a-root-doc","…":"…"}]} ``` Note it returns the `_templates` folder inline — the same folder the template picker uses. `GET /api/users/adron/documents`: ```json {"documents":[{"id":"026028c0-…","title":"Railroad Apps to Build for Fun", "folderId":null,"relativePath":"railroad-passenger-seating.md", "createdAt":"…","updatedAt":"…"}], "folders":[]} ``` ## ⚠️ Backend constraint on the invite pair `POST /api/documents/invite/{token}` and `POST /api/documents/shared/{token}` are both `x-auth-type: session` in the live spec — **a Bearer-only client cannot claim**. Deliver the landing view and hand the accept step to the browser; the backend ask is filed separately. Accepting is documented as always free, so no entitlement gate on the landing view. ## Two authoring gaps **Move a document between folders.** `/help/documents`: *"Open the document, use the **Move to folder** option (or the document settings menu), and choose a destination; select **No folder (root)** to remove it from all folders."* macOS has folder create / rename / delete (`FolderTreeViewModel.swift:114/136/169`) but no move-document action. **Template seed defaults.** `/help/documents`: *"Templates are stored in a special folder named `_templates`, created automatically the first time you open the template picker. A **Seed defaults** action fills it with a starter set (Recipe, Meeting Notes, Release Notes, and more)."* macOS ships `DocumentTemplatePickerView` + `ServerTemplatesViewModel` — **verify** whether the seed-defaults action is present before building it; if it is, this bullet closes as already-done. ## Deliberately deferred `POST` / `DELETE /api/documents/{id}/presence` — the live-cursor heartbeat. It is a collaborative-editing feature with a continuous polling cost, and multi-user simultaneous editing is not currently a goal. **Not in scope for this issue.** (This was `G13`; it is no longer backend-blocked, just low-value.) ## Division of labor **Kit** - [ ] `Documents.tree()`, `Documents.publicDocuments(username:)`, `Documents.createInFolder(folderID:…)`, `Documents.invite(token:)` + DTOs - [ ] Contract tests for the tree shape (folders carry nested `documents`; root documents are a sibling array — do not flatten) **Domain** - [ ] Fold the tree call into `DocumentsService` as the sidebar's single source, keeping the existing SWR cache semantics — the tree replaces several calls, so the cache key and TTL need revisiting, not just the fetch. - [ ] A move-document-to-folder method (including to root). - [ ] Public-documents-by-user projection, shared with the My Profile issue so it is built once. **App** - [ ] `DocumentsSidebarView` paints from the single tree call. - [ ] **Move to folder** in the editor's settings menu and the document list's context menu, with "No folder (root)". - [ ] **New Document** inside a folder creates it there (today's flow depends on the selected folder — verify and use the explicit route). - [ ] A documents column on the profile page (shared with the My Profile issue). - [ ] Invite-landing view, ending in "Accept in your browser". **Tests** - [ ] happy — tree renders nested folders and root documents; move relocates a document - [ ] invalid — move to a folder that no longer exists - [ ] upstream-failure — the tree call fails → the sidebar paints from cache and shows a revalidation error, not an empty tree - [ ] boundary — an empty folder; a folder holding only subfolders; the `_templates` folder is not offered as a normal destination ## Acceptance criteria - The sidebar is built from one call. - A document can be moved between folders and to root from the Mac. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G24**. Size M. | closed | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/52 | Sep 7, 2026, 12:04 PM | Sep 13, 2026, 10:41 PM |
| 51 | feat(lists): finish GitHub-backed list creation and add Refresh from GitHub | ## Summary The macOS New List sheet shows GitHub source fields that do nothing: > `App/Features/Lists/NewListSheetView.swift:85` — `Section("GitHub source (optional)")` > `App/Features/Lists/NewListSheetView.swift:98` — `Text("GitHub source fields will be sent in a future update.")` So a user can fill in a repo and get a plain local list. The web, meanwhile, has a full GitHub-backed list flow, and the client already ships the *reading* half (issue browser, create-issue-from-message, close/reopen, labels/assignees — G4 + the PR #24 route fixes). The missing piece is **creation and refresh**. ## What the web does (`/help/lists`) **Creating:** - Prerequisite: GitHub connected **with the Issues scope**. *"If you already linked GitHub for sign-in, you may need to click **Reconnect for GitHub Issues** to grant access."* - On the **GitHub-backed List** tab, **select a repository you have access to** (a picker, not a free-text field) - List title defaults to the repo name - Optional parent list; optional Public toggle **Behaviour once created:** - Rows map to issues (title, body, labels, assignees, state). Adding a row **creates an issue**; editing a row **updates** it; deleting a row **closes** it. - Schema is **fixed** — Edit changes only the parent list. *"You cannot modify the issue fields … they are defined by GitHub."* - **Refresh from GitHub** pulls the latest issues (the list caches locally). - Visibility is re-read from GitHub on every sync (create, manual refresh, scheduled sync). ## Live facts (2026-09-07) ``` GET /api/github/repos → 200 (test account is now GitHub-linked) but the list is EMPTY GET /api/github/orgs → 200 [] GET /api/auth/github/status → 200 {"configured":true,"clientId":"Ov23li9eXYK1i6psJW6G", "manageOrgAccessUrl":"https://github.com/settings/connections/applications/…"} ``` ⚠️ **The test account has no accessible repos**, so the repo-picker payload and the `githubSource`-on-create request shape **cannot be exercised end-to-end today**. Before building, either grant the `.env` test account access to a repo, or capture the web app's own network calls for a create. Do not guess the create-time field names — `work-consolidation.md` `P3-C` records `githubSource: { owner, repo, path, ref }` as *unconfirmed*. ## Also unconfirmed (`P3-C`) The refresh metadata the UI needs — `lastRefreshedAt`, `refreshStatus` (`idle|pending|failed`), `refreshError` — is modelled in the domain as `GitHubListSource` but has never been seen on the wire. Probe before rendering a refresh state, or render only what is actually returned. ## Division of labor **Kit** - [ ] Accept `githubSource` on `POST /api/lists` once the shape is confirmed. - [ ] A repo-picker request builder over `GET /api/github/repos` (already present — confirm the populated shape against a repo-bearing account). - [ ] Whatever route drives **Refresh from GitHub**; if none exists, file the backend ask rather than inventing one. - [ ] Contract tests against a repo-bearing account. **Domain** - [ ] Extend list creation with a GitHub source, gated on the subscriber entitlement **and** on a GitHub identity with Issues scope. - [ ] Model the fixed-schema rule so the schema editor refuses field edits on a GitHub-backed list and offers only the parent-list change. - [ ] Refresh action + refresh state, only for the fields the probe confirms. **App** - [ ] Replace the dead GitHub section in `NewListSheetView` with a real repo **picker** populated from the account's repos, plus the title default. - [ ] An inline **Reconnect for GitHub Issues** CTA when the identity lacks the scope — reuse the OAuth flow from the Integrations issue rather than building a second one. - [ ] **Refresh from GitHub** in the list toolbar, with its state surfaced. - [ ] Schema editor: parent-list-only mode for GitHub-backed lists, with an explanation rather than a silently disabled form. **Tests** - [ ] happy — pick a repo, create the list, rows arrive as issues - [ ] invalid — GitHub not linked, or linked without Issues scope → the reconnect CTA, no create attempt - [ ] upstream-failure — the repo list 500s → the picker shows an error and the local-list tab still works - [ ] boundary — an account with zero accessible repos (today's test account) → an honest empty picker, not a spinner ## Acceptance criteria - No control in the New List sheet promises something "in a future update". - A GitHub-backed list created on the Mac behaves the same as one created on the web. - Full E2E gate green. ## Notes **New finding from the 2026-09-07 parity sweep.** Depends on `P3-C` (create-time and refresh shapes) and shares the OAuth scope-upgrade flow with the Integrations issue. Size M. | open | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/51 | Sep 7, 2026, 12:02 PM | Sep 16, 2026, 8:01 PM |
| 50 | feat(lists): field validation, help text, add-another-after-saving, From Template tab | ## Summary Four list-authoring affordances the web documents and macOS does not have. None of them needs new API surface beyond what the schema route already accepts — this is mostly editor work. ## 1. Per-field validation and help text `/help/lists` ▸ Schema and fields: > **Field types**: Text, number, date, email, URL, select, checkbox, and more > **Validation**: Required fields, min and max values, patterns > **Help text**: Add hints for each field to guide users macOS today: `SchemaEditorViewModel` validates only the *field name* (non-empty, unique — `SchemaEditorViewModel.swift:157–197`). `ListSchema.swift:6` says `nullable` and `enumValues` are *"reserved for future"*, and there is no min / max / pattern / help-text concept anywhere. ⚠️ **Probe before designing.** The schema DSL is a client convention that is still API-unconfirmed (`P2-G` in `work-consolidation.md`: the `Field:select(a|b|c)` token, delimiter, and whether the server re-emits the option list verbatim). Adding validation metadata to the same DSL string without knowing how the server round-trips it risks silently dropping every rule. Build a list on the web **with** validation and help text, then read `GET /api/lists/{id}/schema` and record the actual encoding before writing the editor. ## 2. "Add another after saving" `/help/lists` ▸ Entering several rows in a row: > Next to the **Add Row** button on the form is an **Add another after saving** checkbox … saving keeps you on the Add Row screen instead. The row is saved, the form empties, a short confirmation tells you how many rows you have added so far, and the cursor returns to the first field. Two details the help text is explicit about, both easy to get wrong: - **The checkbox remembers your choice**, across lists and across visits. - **A rejected save clears nothing** — the values stay in the form so the user can fix and retry. This is a pure keyboard-ergonomics win and fits macOS well. ## 3. "From Template" tab on New List `/help/lists`: *"When creating a list, the **From Template** tab on the New List page lets you start from a pre-defined schema instead of building one field by field."* macOS `NewListSheetView` has no template tab. (The AI **Powered Templates** path *did* ship in PR #31 — this is the non-AI, pre-defined-schema tab, a separate thing.) ## 4. Repository link and Private-repo tag on GitHub-backed lists `/help/lists`: the repository a list came from is linked under the list name (`owner/repo issues`), and a private repository carries a **Private repo** tag — *"the warning that GitHub will ask them to sign in, or show them a 'not found' page, when they follow the link"*. The wire field exists: `githubRepoPrivate` appears on list rows (seen in the `GET /api/lists/watching` payload probed 2026-09-07) and is not decoded by the client. ## Division of labor **Kit** - [ ] Decode `githubRepo` / `githubRepoPrivate` on `ListDTO` if not already present. - [ ] Whatever the schema probe turns up for validation metadata. **Domain** - [ ] Extend `SchemaField` with the validated set once the encoding is known — `isRequired`, `min`, `max`, `pattern`, `helpText` — and keep unknown tokens round-tripping untouched so a web-authored rule survives a macOS save. - [ ] Row-level validation that applies those rules before the network call. - [ ] A `GitHubListSource` projection carrying repo slug + private flag. **App** - [ ] `SchemaEditorView`: per-field validation controls and a help-text field. - [ ] `RowInspectorView` / add-row form: surface help text, enforce validation inline, and add the **Add another after saving** checkbox with a persisted preference plus a running "N rows added" confirmation. Preserve field values on a rejected save. - [ ] `NewListSheetView`: a **From Template** tab. - [ ] List header: repo link + Private-repo tag for GitHub-backed lists. **Tests** - [ ] happy — a required field with help text saves and validates; add-another keeps the form open and counts up - [ ] invalid — a value violating min/max/pattern is rejected client-side with the field's own message - [ ] upstream-failure — a rejected save leaves every typed value intact - [ ] boundary — a schema authored on the web with rules macOS does not yet model survives a macOS edit untouched ## Acceptance criteria - Validation rules and help text set on the web are visible and enforced on the Mac, and survive a Mac-side schema edit. - Bulk row entry never needs the mouse between rows. - Full E2E gate green. ## Notes **New findings from the 2026-09-07 parity sweep** (items 1–4 were not individually recorded). Item 1 is blocked on the `P2-G` schema-DSL confirmation; items 2–4 are unblocked and can ship first. | closed | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/50 | Sep 7, 2026, 12:02 PM | Sep 16, 2026, 8:01 PM |
| 49 | decision(lists): list folders still ship on the web but were removed from macOS | ## Summary List Folders were removed from the macOS app in PR #19 (`refactor/lists-remove-list-folders`, merged 2026-09-06) on the understanding that the feature was retired. **It was not.** The web app still ships a full list-folder tree, the API still serves it, and `/help/lists` documents it in detail as a current subscriber feature. This is now a one-way parity divergence: lists organised into folders on the web appear flat on the Mac, and a `folderId` set elsewhere is invisible and uneditable here. ## Evidence (live, 2026-09-07) The routes are live and Bearer-reachable: ``` GET /api/folders → 200 {"folders":[]} (test account has none) POST /api/folders (subscriber-gated) PUT /api/folders/{id} DELETE /api/folders/{id} ``` Their OpenAPI summaries: - `GET` — *"Get all non-deleted list folders for the authenticated user"* - `POST` — *"Create a new list folder (subscribers only)"* - `PUT` — *"Rename or move a list folder"* - `DELETE` — *"Soft-delete a list folder; detach its lists first"* And every list row carries `folderId` — visible in the `GET /api/lists/watching` payload probed the same day. `/help/lists` ▸ **List folders** (current, subscriber feature): - *"a folder can hold lists, other folders, or both, so you can build whatever hierarchy works best"* - Creating: **New Folder** on the Lists page, name 1–80 chars, optional parent; names unique among siblings - Subfolders: *"There is no separate subfolder action; any folder you create with a parent set becomes a subfolder"* — nest as deeply as you like - Moving a folder: pick a new parent or *"No parent (root)"*; **a folder cannot be moved underneath itself or one of its own descendants** - Moving a list into a folder: the list's Edit Schema view, `Folder` field, or "No folder" to return it to the top level - Renaming; Deleting (*"removes any subfolders inside it, but never deletes lists: any lists in the removed folders are moved back to the top level"*) `/help/getting-started` also lists *"Lists tree: A navigable tree of your lists and list folders"* as a Dashboard element. ## ⚠️ Owner decision required before any code Two defensible positions: 1. **Rebuild it.** The web ships it, the API serves it, and folder structure created on the web is currently invisible on the Mac. Restore feature parity. 2. **Keep the divergence deliberately.** The macOS app already has owned-list `parentID` parent/child nesting (unaffected by PR #19 and still shipping), which covers much of the same organising need. If folders are genuinely being retired platform-wide, the right move is to leave macOS as-is and let the web catch up. **What is not defensible is the current state**, where the divergence is undocumented and reads as an oversight. Whichever way this goes, record the decision in `docs/decisions/` so the next parity sweep does not re-file this issue. ⚠️ Note that GitHub issue **#28** ("New Folder still shows up in Lists") was closed as stale against a pre-PR-#19 binary. That was correct at the time and is unrelated to this — that issue was about a *leftover* control, this is about the *absent feature*. ## Division of labor — only if the decision is "rebuild" **Kit** - [ ] Restore `FoldersEndpoint` (the four routes above) and its DTO. Much of this can be recovered from git history: `git show 1afb89d` is the removal commit. - [ ] Add `folderId` to the list create/update request bodies. - [ ] Contract tests against a folder actually created on the test account (there are none today, so the populated shape is unverified — create one and record it). **Domain** - [ ] `ListFolder` model + a tree projection that handles arbitrary nesting. - [ ] `ListsService` folder CRUD, with create gated on the subscriber entitlement. - [ ] Enforce the documented invariants client-side so the user gets an immediate answer instead of a server error: unique name among siblings, 1–80 chars, and **no move under self or a descendant** (a cycle check). - [ ] Deleting a folder cascades to subfolders and **re-parents its lists to root** — model this so the UI can warn accurately. **App** - [ ] Folder tree in the Lists sidebar with create / rename / move / delete. - [ ] A `Folder` picker in the list's schema-edit view, including "No folder". - [ ] Delete confirmation that states exactly what happens to subfolders and to the lists inside. **Tests** - [ ] happy — create, rename, move, delete round-trip - [ ] invalid — duplicate sibling name; 0-char and 81-char names; moving a folder under its own descendant - [ ] upstream-failure — delete rejected because lists must be detached first → actionable message, tree unchanged - [ ] boundary — deeply nested folders; deleting a folder with subfolders and lists ## Acceptance criteria - A decision is recorded in `docs/decisions/`, whichever way it goes. - If rebuilt: folder structure created on the web is visible and editable on the Mac, and vice versa. - Full E2E gate green. ## Notes **New finding from the 2026-09-07 parity sweep.** This effectively re-opens the old G6. Sized M if rebuilt, XS if the divergence is confirmed deliberate. | open | wontfixparityneeds-decision | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/49 | Sep 7, 2026, 12:02 PM | Sep 16, 2026, 8:06 PM |
| 48 | feat(lists): shared-with-me, contributors, add-watcher and invite landing - G23 | ## Summary Five live Lists routes the client does not build. Together they are the whole "lists other people gave me access to" story, which the macOS sidebar has no equivalent of at all. ## The gap, route by route | Route | Live status | What it unlocks | |---|---|---| | `GET /api/lists/watching` | ✅ 200 with real rows (probed 2026-09-07) | **Watched / shared-with-me lists** — the datagrid the web shows on `/lists` | | `GET /api/lists/{id}/contributors` | exists | The full ranked contributor list | | `POST /api/lists/{id}/watchers` | exists, **subscriber-gated** | Add a watcher. The client can read and delete watchers but not add one | | `GET /api/lists/shared/{token}/data` | exists, public | Row data for a token-shared list — the read-only viewer's missing half | | `GET /api/lists/invite/{token}` | exists, public | The email-invite landing page | ## Live shape for `GET /api/lists/watching` (2026-09-07) ```json {"lists":[{"id":"1df0fe30-…","userId":"c65092fa-…","messageId":null, "parentId":"7835c874-…","folderId":null, "title":"Shows Upcoming & Seen","description":null,"isPublic":true, "metadata":null,"source":"local","githubRepo":null,"githubRepoPrivate":null, "createdAt":"…","updatedAt":"…","deletedAt":null, "user":{"id":"c65092fa-…","username":"adron","displayName":"Adron Hall"}, "parent":{"id":"7835c874-…","title":"The Metal"}, "children":[],"role":"collaborator"}]} ``` Note it carries **`role`** (the caller's role on that list), an embedded **`user`** (the owner), and a **`parent`** projection — everything the web's datagrid column set needs (*"Each entry shows the list title, owner, your role, and a link to view the list"*, `/help/lists`). It also carries `folderId` and `githubRepoPrivate`, which matter to two sibling issues (list folders; GitHub-backed lists). ## ⚠️ Backend constraint on the invite pair `GET /api/lists/invite/{token}` is public, but `POST /api/lists/invite/{token}` (the **accept** half) is declared `x-auth-type: session` in the live spec — **a Bearer-only client cannot claim an invite**. Same for `POST /api/lists/shared/{token}`. So this issue delivers the *landing* experience (show what the invite grants, who sent it, which list) and must hand the **accept** step to the browser until the backend exposes a Bearer-reachable claim route. That backend ask is filed separately. Do not ship a native Accept button that 401s. Accepting an invite is documented as **always free**, so no entitlement gate belongs on the landing view. ## Roles vocabulary (`/help/lists`) UI label → API role name: **Read-only** → `watcher`, **Edit** → `collaborator`, **Admin** → `manager`. The domain already uses these names in `Sharing.swift`; keep the UI labels aligned with the web's. ## Division of labor **Kit** - [ ] `Lists.watching()` → `GET /api/lists/watching` - [ ] `Lists.contributors(id:)` → `GET /api/lists/{id}/contributors` - [ ] `Lists.addWatcher(listID:userID:)` → `POST /api/lists/{id}/watchers` - [ ] `Lists.sharedData(token:)` → `GET /api/lists/shared/{token}/data` - [ ] `Lists.invite(token:)` → `GET /api/lists/invite/{token}` - [ ] DTOs for each, plus contract tests. `watching` rows are a **superset** of the owned-list shape — reuse the existing `ListDTO` where it fits rather than forking it, and add `role`/`user`/`parent` as optionals. **Domain** - [ ] A `WatchedList` projection (list + owner + caller's role) and a `ListsService.watching()`. - [ ] `contributors(listID:)` and `addWatcher(...)`, the latter gated on the subscriber entitlement (see the entitlement issue). - [ ] Extend the existing share-token resolve path with the row-data call so a read-only viewer sees rows, not just the schema. **App** - [ ] A **Shared with me** section in the Lists sidebar (or a segmented control on the Lists root), showing title, owner, and your role — mirroring the web's datagrid. - [ ] Wire `AddWatcherSheetView` (already present) to the real add-watcher call. - [ ] Contributors view on a list's detail. - [ ] The read-only token-shared list renders its rows. - [ ] An invite-landing view reachable from `interlinedlist://…/invite/{token}`, ending in "Accept in your browser" until the claim route is Bearer-reachable. **Tests** - [ ] happy — watching returns rows grouped by role; contributors ranked; watcher added - [ ] invalid — add-watcher for a user who is already a watcher; an unknown token - [ ] upstream-failure — `watching` 500s → the sidebar section shows an error, owned lists still render - [ ] boundary — zero watched lists; a watched list whose parent is not itself watched (the `parent` projection must not imply access) ## Acceptance criteria - A list someone shared with me appears in the macOS sidebar without me knowing its URL. - Opening a share-link as a viewer shows the rows. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G23**. Size M. | closed | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/48 | Sep 7, 2026, 12:02 PM | Sep 13, 2026, 10:40 PM |
| 47 | feat(settings): the Integrations surface — verify, disconnect, and per-provider config (G33) | ## Summary The web `/integrations` page manages every connected provider in one place — link, **verify**, **disconnect**, and per-provider configuration. The macOS `LinkedAccountsView` can only **link**. Once an account is connected there is no way to check it, reconfigure it, or remove it from the Mac. This issue supersedes **G26** (identity unlink + verify) and absorbs the personal-scope half of **G25**'s LinkedIn work. ## What the web offers, per provider (`/help/settings`, `/help/account`) | Provider | Link | Verify | Disconnect | Extra | |---|---|---|---|---| | **GitHub** | ✅ | ✅ | ✅ | Org access + "Update orgs"; **default repo**; **Reconnect for GitHub Issues** (scope upgrade) | | **Bluesky** | ✅ | ✅ | ✅ | — | | **LinkedIn** | ✅ | ✅ | ✅ | Posting-target editor; company-page sync | | **Mastodon** | ✅ | ✅ | ✅ | **Multiple instances**, each independently verifiable and disconnectable | | **X (Twitter)** | ✅ | ✅ | ✅ | Cross-posting only | Plus an **Integration reconnect reminders** preference: *"When it's on (the default), InterlinedList reminds you when one of your cross-posting connections (such as LinkedIn) is expiring or needs reconnecting, so your cross-posts don't quietly stop."* And the GitHub-backed-lists prerequisite: *"If you already linked GitHub for sign-in only, click **Reconnect for GitHub Issues** in the Connected Accounts section to grant the additional scope."* ## What macOS ships today - `LinkedAccountsView` — link only. - `IdentityProvider.linkableProviders` — **X/Twitter is absent entirely**, even though the test account has a linked X identity and cross-posting to X is a shipped feature. - `UserDTO.githubDefaultRepo` (`UserDTO.swift:96`) is decoded and **never written**. - Kit lacks `DELETE /api/user/identities`, `POST /api/user/identities/verify`, `GET /api/auth/github/status`, `GET /api/github/orgs`, `PUT /api/linkedin/posting-targets`, and `POST /api/linkedin/sync-pages`. ## Live probes (2026-09-07, Bearer, all read-only) ``` GET /api/auth/github/status → 200 {"configured":true,"clientId":"Ov23li9eXYK1i6psJW6G", "manageOrgAccessUrl":"https://github.com/settings/connections/applications/Ov23li9eXYK1i6psJW6G"} GET /api/github/orgs → 200 [] ← bare array, not wrapped ``` `manageOrgAccessUrl` is exactly the "Update orgs" affordance — open it in the browser rather than trying to render GitHub's org-access UI natively. Note the wire shape: `/api/github/orgs` returns a **bare array**, while most single-resource routes on this API answer `{message?, <resource>}`. Decode accordingly and add a contract test — this is the kind of drift that has bitten this client before. ## Division of labor **Kit** - [ ] `DELETE /api/user/identities` (unlink) - [ ] `POST /api/user/identities/verify` - [ ] `GET /api/auth/github/status` — the one provider-status route Kit lacks (`bluesky`/`mastodon`/`linkedin`/`twitter` are already there) - [ ] `GET /api/github/orgs` (bare-array DTO) - [ ] `PUT /api/linkedin/posting-targets` - [ ] `POST /api/linkedin/sync-pages` - [ ] Add `githubDefaultRepo` to `UpdateUserRequest` - [ ] Contract tests for each new shape against the `.env` test account **Domain** - [ ] Extend the identity model to carry per-provider capability (verifiable / disconnectable / multi-instance) so the UI is data-driven rather than a switch statement per provider. - [ ] Mastodon must be modelled as a **collection** of instances, not a single identity — this is the one that will not retrofit cleanly if it is modelled as a scalar first. - [ ] Add X/Twitter to `linkableProviders`. - [ ] LinkedIn posting-target read/write, closing the "needs a verified per-target wire shape" note left on G11a. **App — a new Integrations pane** - [ ] One row per provider, with Link / Verify / Disconnect as the row's actions and its status inline. - [ ] Disconnect is destructive and breaks cross-posting — confirm before firing, and say what stops working. - [ ] GitHub: default-repo field, an "Update orgs" button that opens `manageOrgAccessUrl` in the browser, and the **Reconnect for GitHub Issues** scope-upgrade path (reuses the existing `ASWebAuthenticationSession` flow). - [ ] Mastodon: a list of instances with per-instance actions. - [ ] LinkedIn: posting-target selector + company-page sync. - [ ] "Integration reconnect reminders" toggle — confirm which stored field backs it before wiring. **Tests** - [ ] happy — link, verify, and disconnect each provider - [ ] invalid — disconnecting a provider that is not linked - [ ] upstream-failure — verify returns non-200 → row shows a failed state, other rows unaffected - [ ] boundary — zero linked providers; several Mastodon instances; disconnecting the last provider used for sign-in ## Acceptance criteria - Every provider the web can manage can be linked, verified, and disconnected from macOS. - A GitHub account linked for sign-in only can be upgraded to Issues scope without leaving the app. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G33** (supersedes G26, overlaps G25). Size **L** — the largest single item in the account cluster. Consider splitting the Mastodon multi-instance work into its own PR if the diff gets unwieldy. --- ## Implementation plan (added 2026-09-15) — split, as this issue anticipated This issue is sized **L** and says *"consider splitting the Mastodon multi-instance work into its own PR if the diff gets unwieldy."* The probe changed which half is urgent: **Mastodon is not a modelling preference, it is a live defect**, so it leads rather than being split off. ### What the probe found (2026-09-15) `GET /api/user/identities` on the test account: ```json {"identities":[ {"provider":"bluesky","providerUsername":"interlinedlist.bsky.social", …}, {"provider":"mastodon:techhub.social","providerUsername":"interlinedlist_crew@techhub.social", …}, {"provider":"twitter","providerUsername":"interlinedlist", …}, {"provider":"github","providerUsername":"InterlinedListMessenger", …}]} ``` Three defects, all live: 1. **Mastodon arrives instance-qualified.** `IdentityProvider.init(wireToken:)` matched `"mastodon"` exactly, so `"mastodon:techhub.social"` fell to `.other` — **every Mastodon identity rendered as an unknown provider**, and the instance host was discarded entirely. The doc comment claimed the host was *"carried on the `LinkedIdentity`"*; there was no such field. 2. **X was absent from the provider enum**, as this issue noted — and the consequence is sharper than "missing from a list": a linked X identity decoded as `.other("twitter")`, so the one provider the account could already cross-post to was the one it could not recognise. 3. **`GET /api/user/identities` accepts Bearer**, contrary to its `.session` annotation. Not a hard failure — the kit has a lazy cookie-login transport — but a redundant credentialed round-trip on every load, and the same class of error as the `send-verification-email` correction in PR #83, in the opposite direction. ### Shipped in this PR **Kit** — `DELETE /api/user/identities`, `POST /api/user/identities/verify`, `GET /api/auth/github/status`, `GET /api/github/orgs` (bare array, as recorded); `identities()` re-annotated `.bearer`. **Domain** — `.twitter` added; `mastodon:host` split in `init(wireToken:)` with `instanceHost(fromWireToken:)`; `LinkedIdentity.instance` plus `providerWireToken`, which is what unlink and verify must send; per-provider capability flags (`supportsMultipleInstances`, `requiresInstanceHost`, `isVerifiable`, `isDisconnectable`) so the pane is **data-driven rather than a switch statement per provider**, which is what this issue asked for; `displayName` moved into the domain next to them; `GitHubConnection`; `unlinkIdentity` / `verifyIdentity` / `githubConnectionStatus`. **App** — the Integrations pane: one row per connection with Verify and Disconnect, a confirmation that names **what stops working** per provider rather than a generic "are you sure", per-row status and per-row errors, Mastodon as one row per instance, and GitHub's "Update organization access…" opening `manageOrgAccessUrl` in the browser. ### Deliberately deferred, and why | Deferred | Reason | |---|---| | LinkedIn posting-target **editing** (`PUT /api/linkedin/posting-targets`) | The read returns `{"targets":[],"orgScopeMissing":false}` on this account — **no populated payload has been seen**. Building a target editor against an empty array is how the G21 and G25 silent-decode defects happened. It is also the supply side #79 needs, so it belongs with that work. Note `orgScopeMissing` is an undocumented field this issue did not mention. | | `githubDefaultRepo` write, **Reconnect for GitHub Issues** | `GET /api/github/repos` returns `[]` — the test account has no accessible repositories, exactly as #51 records. A repo picker cannot be exercised end to end, and the scope-upgrade path shares the OAuth flow with #51. | | **Integration reconnect reminders** toggle | The issue says *"confirm which stored field backs it"*. It is not on `GET /api/user`, not on `/api/user/notification-preferences` (which returns an event catalogue with `push`/`inApp` channels and no reconnect key). The backing field is unidentified, so nothing is wired to a guess. | | Linking itself | Stays in the Linked accounts pane, which owns the `ASWebAuthenticationSession` and the Mastodon instance prompt. Two copies of an OAuth handshake is worse than two panes. | Each of these is blocked on evidence rather than effort. | closed | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/47 | Sep 7, 2026, 7:25 AM | Sep 16, 2026, 7:30 PM |
| 46 | feat(settings): Profile settings — display name, bio, avatar, theme, message length, password (G34) | ## Summary **You cannot edit your own display name or bio from the macOS app at all.** `UpdateUserRequest` already carries `displayName`, `bio` and `theme` (`UserDTO.swift:169–171`) and nothing calls them. Several other documented Profile settings are equally absent. ## The documented pane (`/help/settings` ▸ Profile settings) | Setting | Documented | macOS today | |---|---|---| | Display Name | *"How you appear to others (defaults to your username if not set)"* | ❌ | | Bio | *"Short description shown on your public profile"* | ❌ | | Avatar | *"upload a file or set from a URL"* | ❌ | | Theme | Light / dark / system | ❌ (deliberately deferred — values were unconfirmed) | | Max message length | *"Character limit per message (default 666)"* | ❌ | Plus, from Security: **Change password** and **Forgot password**. ## Live facts (probed 2026-09-07) `GET /api/user` on the test account returns `"theme":"light"` and `"maxMessageLength":666`, so both are real, populated fields. `POST /api/user/avatar/from-url` exists in `UserEndpoint` with **no** `UserServicing` method behind it. `POST /api/auth/forgot-password` is already in Kit (`AuthEndpoint.swift:19–26`) and is not wired to any UI. ⚠️ **There is no change-password route in the live spec.** The only password routes are `POST /api/auth/forgot-password` and `POST /api/auth/reset-password` (plus an admin-only one). The web's "Change password: enter your current password and a new one" has no documented public endpoint. **Probe before building** — either find the route the web form posts to, or implement the reset-by-email flow and say so plainly in the pane. Do not guess a path. ## Two things to get right 1. **Theme.** `UserSettings` deliberately omits it because the valid value set was unconfirmed. The web offers exactly three (System / Light / Dark) and the live account holds `"light"`. Probe `PATCH /api/user/update` with each of the three before widening the typed surface, and keep an `.unknown(String)` case so a future value cannot fail the decode. 2. **Max message length is not the server ceiling.** `GET /api/limits` (shipped as G14) is the *platform* maximum; `maxMessageLength` on the account is a **user-chosen cap** underneath it. The composer must honour the *lower* of the two, and the pane must say which is which. Getting this backwards would let a user set a limit the server rejects, or silently raise their own ceiling past the platform's. `maxMessageLength` is on `UserDTO` but **absent from `UpdateUserRequest`** — that is a Kit change, not just a UI one. ## Division of labor **Kit** - [ ] Add `maxMessageLength` to `UpdateUserRequest`. - [ ] Confirm `avatar` upload: `POST /api/user/avatar/from-url` is present; check whether a multipart file-upload route also exists and add it if so. - [ ] Probe and add the change-password route, or record its absence in the issue and fall back to reset-by-email. **Domain** - [ ] Extend `UserSettings` (or a sibling `ProfileSettings`) with `displayName`, `bio`, `theme`, `maxMessageLength`; keep the existing change-gated PATCH discipline so untouched fields are never clobbered. - [ ] `UserServicing` methods for avatar-from-URL and avatar-file-upload. - [ ] A `ContentLimits` + account-cap reconciliation helper so the composer has one number to ask for. - [ ] Password reset / change service method. **App** - [ ] A Profile pane in Settings: display name, bio, avatar (file picker **and** from-URL field, matching the web), theme picker, max-message-length stepper. - [ ] Reuse the existing change-gated Save pattern from `PreferencesView` — do not introduce a second save idiom. - [ ] Security section: change password (or reset-by-email) and forgot-password. - [ ] Theme: applying it must actually drive the app's appearance, not just persist a string. Decide whether the account theme overrides the system appearance or seeds it, and document the choice. **Tests** - [ ] happy — each field round-trips through PATCH - [ ] invalid — empty display name falls back to the username; over-long bio rejected client-side - [ ] upstream-failure — PATCH fails → prior values restored, error surfaced, nothing half-saved - [ ] boundary — `maxMessageLength` at, below, and above the `GET /api/limits` ceiling; composer honours the lower of the two ## Acceptance criteria - A user can set their display name, bio, and avatar without leaving the app. - The composer's character budget matches whatever the account cap says. - No password UI ships pointed at an unverified route. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G34**. Size M. --- ## Implementation plan (added 2026-09-15) — all four probes done Every "probe before building" in this issue was resolved live on 2026-09-15. ### 1. Theme — the three work, and so does anything else ``` PATCH /api/user/update {"theme":"system"} → 200, stored "system" {"theme":"dark"} → 200, stored "dark" {"theme":"light"} → 200, stored "light" {"theme":"auto"} → 200, stored "auto" {"theme":"nonsense"} → 200, stored "nonsense" ``` **The field is unvalidated.** So `.unknown(String)` is not defensive padding — it is the documented behaviour, and the picker must *offer* the account's own value when it is unrecognised. Otherwise the `Picker` has a selection matching no tag (SwiftUI renders a blank control) and the first edit to any other field silently rewrites the theme. Pinned by a test that asserts the PATCH body omits an untouched unknown theme. ### 2. There is no change-password route The live spec carries only `/api/auth/forgot-password`, `/api/auth/reset-password`, and an admin-only `/api/admin/users/{userId}/password`. The web's "enter your current password and a new one" form has **no public endpoint**. So the pane ships **reset-by-email and says so plainly**. No password UI points at an unverified route, which is this issue's own acceptance criterion. ### 3. `maxMessageLength` — and the ceiling trap, confirmed reachable ``` GET /api/limits → message.maxContentLength: 5000 ← the platform PATCH {"maxMessageLength":500} → 200 PATCH {"maxMessageLength":99999} → 400 "must be a positive integer between 1 and 10000" PATCH {"maxMessageLength":0} → 400 same ``` The account range reaches **10000** while the platform stops at **5000** — so a user really can set a cap *above* the ceiling. This issue named the trap and the numbers make it reachable rather than theoretical. - `ContentLimits.effectiveMessageLength(accountCap:)` is the **single place** that decides, returning the lower of the two. - `ComposerViewModel.refreshLimits()` used the platform ceiling **alone**, ignoring a cap the user had deliberately set. Fixed here. - `CurrentUser` carries `maxMessageLength`, for the same reason it carries `defaultPubliclyVisible`: it is on the `GET /api/user` payload already, so the composer gets it session-cached with no extra round-trip. - The setter clamps to `1...10000`, so no caller can earn the 400. - The pane shows **both** numbers and warns when the account cap is the one not in force. ### 4. Avatar — both halves exist `POST /api/user/avatar/upload` **and** `POST /api/user/avatar/from-url`. The pane offers a file picker and a URL field, matching the web. `uploadAvatar` already had a service method; `setAvatarFromURL` did not. The avatar is written by its own route, **outside** the change-gated body — so both the working copy and the saved snapshot are updated, or Save would light up claiming an unsaved change that does not exist. ### Division of labour as built **Kit** — `UpdateUserRequest` += `maxMessageLength` (it was on `UserDTO` and absent from the request, so the value was readable and unwritable). **Domain** — `ProfileSettings` + `AppTheme` + `ProfileLocation`; `ContentLimits.effectiveMessageLength(accountCap:)`; `CurrentUser.maxMessageLength`; `UserServicing.profileSettings` / `updateProfileSettings(_:changedFrom:)` / `setAvatarFromURL` / `requestPasswordReset`. `ProfileSettings` is kept separate from `UserSettings` on purpose: same account, same PATCH route, different question — this is "who am I", that is "how does the app behave". **App** — `ProfileSettingsView` + view model, registered as a Settings tab. Reuses `PreferencesView`'s change-gated Save idiom rather than introducing a second save shape, as this issue asked. ### Profile location — read-only, and #57 is why Folded in from #57, which was re-scoped after its own probe: a location can be **set** through the API and **cleared through nothing** (#91). The pane therefore *shows* a published location — closing the "invisible" half of #57 — and offers no setter, because shipping set-without-clear would make macOS a way to publish an approximate home location that the user can never take back. ### On the theme not driving appearance This issue asked whether the account theme should override or seed the system appearance. **Neither, for now**: the value is stored on the account and applies on the web, and the Mac app follows the system appearance. Driving `NSApp.appearance` from a server preference is a behaviour change worth its own decision, and the pane says plainly what the setting does today rather than implying more. | closed | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/46 | Sep 7, 2026, 7:23 AM | Sep 16, 2026, 7:30 PM |
| 45 | feat(nav): nest the account rows under a Profile group in the sidebar (G31) | ## Summary On the web, Settings and Integrations live **inside** the profile/account menu. The native app inverts that: Settings is an unrelated `⌘,` scene, "Integrations" does not exist as a concept, and the sidebar is ten flat peers with no account grouping at all. ## The web's account menu (walked live 2026-09-07) ``` My User Page · Dashboard · Messages · My Organizations · Documents · Lists · Settings ▸ { Preferences, Integrations, Subscription } · Switch to ▸ · Log out ``` ## Proposal Group the account-shaped sidebar rows under a `Profile` `DisclosureGroup`: - **My Profile** (the new own-account page) - **Organizations** (moved) - **Connections** (moved) - **Settings** - **Integrations** (the new surface) ## ⚠️ HIG constraint — read before writing code The App menu's `Settings…` (`⌘,`) item and `SettingsRootView` must keep working **exactly** as they do today. macOS convention is that Settings is its own window, and re-homing the panes into the detail column would break that expectation and the keyboard shortcut users already have. So the sidebar rows are an **additional entrance** that opens the Settings scene via `SettingsLink` — never a relocation of the panes. Same for Integrations: the row opens the Settings scene on the Integrations tab. ## Division of labor **App — navigation only. No Kit or Domain changes.** - [ ] `App/Navigation`: introduce the `Profile` disclosure group and move `Organizations` and `Connections` under it. - [ ] `My Profile` row → the own-account page. - [ ] `Settings` and `Integrations` rows → `SettingsLink`, landing on the right tab. - [ ] Every existing deep link that sets `selection` must still land, **expanding the parent group automatically** — a deep link into Organizations cannot leave the user staring at a collapsed group. - [ ] Persist the group's expanded/collapsed state across launches. - [ ] Decision 0003 still applies: no `import InterlinedKit` under `App/Navigation/**`. **Tests** - [ ] happy — the group renders with all five rows, `⌘,` still opens Settings - [ ] invalid — a deep link to a row inside a collapsed group expands it and selects the row - [ ] upstream-failure — n/a (no I/O); instead assert the group renders when the current user is not yet loaded - [ ] boundary — collapsed/expanded state survives a relaunch; selection survives a group toggle ## Acceptance criteria - `⌘,` and File ▸ Settings behave identically to today. - No Settings pane is reachable *only* from the sidebar, and none *only* from `⌘,`. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G31**. Size S–M. Do this **after** the My Profile page and the Integrations surface exist, otherwise the group has two dead rows. --- ## Implementation plan (added 2026-09-15) Sequenced as this issue asked — **after** My Profile (#44) and the Integrations surface (#47), so the group has no dead rows. ### The group `Profile` `DisclosureGroup` holding **My Profile · Organizations · Connections · Settings · Integrations**. The three reading surfaces that moved in were previously top-level peers; the seven that stayed (Search, Timeline, Scheduled, Notifications, Messages, Lists, Documents) are reading surfaces, not account management, and grouping them would be the web's IA applied where it does not fit. ### The HIG constraint, honoured `⌘,` and File ▸ Settings are untouched. The sidebar rows use **`SettingsLink`**, so they are an *additional entrance* to the same scene — three doors into one window, never a relocation of the panes into the detail column. ### Landing on the right tab `SettingsLink` opens the Settings window but **cannot address a tab**. So `SettingsRootView`'s `TabView` gains a `selection` bound to `@AppStorage("settings.selectedTab")`, and the sidebar row stores its target before the link opens the scene. Two details that follow from that: - The **Settings** row targets **Preferences** explicitly rather than "whatever was open last" — a row labelled Settings that opens Crash Reporting reads as a bug. - `SettingsTab`'s raw values are persisted, so renaming a case would silently send an existing user to a different pane. There is a test pinning them. ### Deep links expanding the group This issue's sharpest requirement: *"a deep link into Organizations cannot leave the user staring at a collapsed group."* Every `selection = …` in `MainWindowView` — thirteen of them, across the menu commands, notification routing, search routing and share-link resolution — now goes through one `select(_:)` that opens the group first when the target lives inside it. Expanding is deliberately **one-way**: a deep link to a top-level row does *not* collapse the group. The user's choice to keep it open is not something a menu command should undo. `MainWindowView.profileGroupSections` is the single list that both the group's contents and `select(_:)` read, so a row cannot be moved into the group without deep links learning about it. ### Persistence `@AppStorage("sidebar.profileGroup.expanded")`, defaulting to **expanded**: on first run the three rows that moved in were previously top-level, and collapsing them by default would look like they had been removed. ### Tests Per the project's view-layer rule, the SwiftUI tree is not rendered. What is tested is the part with real logic and a real failure mode: the group's membership, that no section is orphaned by the partition, and that the persisted `SettingsTab` identities round-trip. ### Acceptance - ✅ `⌘,` and File ▸ Settings behave identically to today. - ✅ No Settings pane is reachable *only* from the sidebar, and none *only* from `⌘,`. - ✅ Every deep link still lands, expanding the group when needed. - ✅ Expanded state survives a relaunch. | closed | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/45 | Sep 7, 2026, 7:21 AM | Sep 16, 2026, 7:30 PM |
| 44 | feat(profile): My Profile — the signed-in user's own page (G32) | ## Summary The macOS app has no view of your own account. `ProfileRootView` opens on an empty *"Enter a username to view their public profile."* prompt (`ProfileRootView.swift:169`) even though `currentUserStore.currentUserID` is already known — so the one profile every user wants to see is the one that takes the most typing to reach. ## What the web shows at `/user/{handle}` - Avatar, display name, `@handle`, bio - **Five** stat tiles: Followers, Following, Posts, Documents, Lists - A **Public Lists** column (a tree, with a **Watch** button per list when you are signed in and it is not your own profile) - A **Public Documents** column - A **Follow** button, and a **Message** button once you and the other person follow each other The native header shows followers/following plus mutuals, and no content columns at all. Profile URL details worth honouring in any deep-link handling: handles are **case-insensitive** (`/user/adron` == `/user/Adron`), `/@username` is a shortcut, and usernames are `[A-Za-z0-9_.-]` (anything else typed at signup becomes `_` in the URL while the display name keeps what was typed). ## Data available (probed live 2026-09-07) `GET /api/users/{username}/documents` → **200**, and it returns folders too: ```json {"documents":[{"id":"026028c0-…","title":"Railroad Apps to Build for Fun", "folderId":null,"relativePath":"railroad-passenger-seating.md", "createdAt":"…","updatedAt":"…"}], "folders":[]} ``` The **public-lists** equivalent still needs probing — find the route the web profile page calls for another user's public list tree before designing that column. `GET /api/lists/watching` (also verified live) is the *watched* lists surface and is a different thing; it is covered in the Lists issue. ## Division of labor **Kit** - [ ] `GET /api/users/{username}/documents` request builder + DTO (documents **and** folders). - [ ] The public-lists-by-user route, once probed. **Domain** - [ ] Extend the public-profile model with post/document/list counts and the two content collections. - [ ] A `selfProfile` path that loads from the signed-in account rather than the public-author fallback. ⚠️ **Decision 0003 / Decision 0002 constraint:** the `profileUnavailable` empty state must **never** fire for your own account. **App** - [ ] `ProfileRootView`: when no username is entered and a user is signed in, land on **your own** profile. Keep the lookup field as a way to visit someone else. - [ ] Five stat tiles; Public Lists and Public Documents columns. - [ ] A **Watch** action per list when viewing someone else's profile (needs `POST /api/lists/{id}/watchers` — shared with the Lists issue; coordinate so it is built once). - [ ] Keep the existing Follow and Message buttons; hide Follow on your own profile. - [ ] Handle deep links case-insensitively and accept the `@handle` form. **Tests** - [ ] happy — signed in, no input → own profile with all five counts - [ ] invalid — unknown username → the existing `profileUnavailable` state, and assert it cannot fire for the signed-in user - [ ] upstream-failure — the documents call fails while the profile succeeds → the profile still renders with that column in an error state, not a whole-page failure - [ ] boundary — zero lists / zero documents → empty columns, not missing columns; mixed-case and `@`-prefixed handles resolve ## Acceptance criteria - Opening Profile while signed in shows your own account with no typing. - Public lists and public documents are visible for yourself and for others. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G32**. Size M. Pairs with the sidebar-IA issue — build the page first, then re-home it. --- ## Implementation plan (added 2026-09-15) ### The probes the issue asked for **The public-lists route is found.** `GET /api/users/{username}/lists` → `{lists[], pagination}`, confirmed live 2026-09-15. It is the public browse collection, distinct from `GET /api/lists/watching` (the caller's own watched surface). **Four of the five stat tiles need no new API at all.** `GET /api/users/{username}` already returns them: ```json {"followerCount":1,"followingCount":1,"publicMessageCount":31,"publicListCount":0, "headerImage":null,"bio":"…","joinedAt":"…","isPrivate":false} ``` `PublicProfileDTO` has decoded `publicMessageCount` and `publicListCount` since the endpoint shipped — they were **dropped at the domain boundary**. So this is a carry-through, not an integration. The fifth tile (Documents) has no count on that payload and comes from the documents column. ### Kit Nothing. Every route and field needed is already modelled. ### Domain - `UserProfile` gains `publicMessageCount`, `publicListCount`, `headerImageURL`, carried in `init(from: PublicProfileDTO)` **and** in `withCounts(_:)` — the follow-counts stitch rebuilds the profile, and not carrying them there would blank two tiles every time that follow-up landed. - `ListsServicing.watch(listId:)` — the route's *self-subscribe* branch (`POST …/watchers` with no `userId`), which is **free**. Modelled as its own method rather than an optional parameter on `addWatcher`, because "add this person" and "subscribe me" are different intents that happen to share a URL — and `addWatcher`'s empty-id guard must keep erroring rather than quietly becoming this. ### App - **`ProfileRootView` lands on the signed-in user's own profile.** `loadOwnProfileIfNeeded()` is a no-op when a profile is already loaded, so re-entering the tab does not yank the user off whoever they were browsing. - **`ProfileStatTilesView`** — the five tiles. A tile whose count is unknown is **omitted, not rendered as zero**: "0 posts" and "we could not find out how many posts" look identical and mean opposite things. - **`PublicUserListsView` / `PublicUserListsViewModel`** — the lists column, mirroring `PublicUserDocumentsView`'s self-contained shape. Watch is optimistic with rollback and per-row error reporting; it is offered only when the profile is someone else's *and* a session exists. - `PublicUserDocumentsView` gains an `onCountChange` callback so the Documents tile reuses the fetch the column already makes — one request, and the tile and the column can never disagree. - **Handle normalisation** (`ProfileViewModel.normalizedHandle`): strips a leading `@` and lowercases, since handles are case-insensitive and `/@username` is a documented shortcut. Legal username punctuation passes through untouched rather than being sanitised against a charset the client would get subtly wrong. - **Ownership is compared on `id`, not handle** — a handle comparison would go wrong exactly where it matters, on a rename. ### The `profileUnavailable` guard Decision 0002's empty state means *"this user has no public messages, so there is nothing to project a profile from"* — a statement about **other people's** public content. Shown for your own account it tells a brand-new user with nothing posted yet that their profile does not exist. It is now suppressed for self and still fires for everyone else, with a test in **both** directions. ### Out of scope The **Message** button already exists. The sidebar re-homing is #45, which this unblocks. | closed | enhancementparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/44 | Sep 7, 2026, 7:21 AM | Sep 16, 2026, 7:30 PM |
| 43 | bug(settings): View Preferences parity — viewing filter and tray limit missing, page-size range wrong (G35) | ## Summary Settings ▸ Preferences shipped 2026-08-16 with three of the five documented View Preferences, and one of the three it does ship has a range the web cannot represent. ## The documented pane (`/help/settings`, re-read 2026-09-07) | Setting | Documented range / values | macOS today | |---|---|---| | Messages per page | **10 to 30** | 🐞 `5...100 step 5` (`PreferencesView.swift:57`) | | Viewing preference | My Messages / All Messages / Followers Only / Following Only | ❌ absent | | Show link previews | on / off | ✅ shipped (and now backed by real previews) | | Notification tray limit | **10 to 40**, default 20 | ❌ absent | Also in the same family, under Message settings: **Default message visibility** ✅ (shipped, PR #34) and **Advanced post settings** — verify the latter is present and actually controls the composer's gear affordance the way the web describes. ## Why the page-size range is a real bug, not a nit `PreferencesView` lets a user save `5` or `100`. The web control offers 10–30, so a value saved from macOS may be unrepresentable there, and the server's own validation is unverified at those extremes. Narrow the native control to the documented range and clamp on load so an already-saved out-of-range value is corrected rather than re-sent. ## `viewingPreference` — the one with a dependency `UserDTO.viewingPreference` is decoded (`UserDTO.swift:89`) and deliberately excluded from `UserSettings`, so the timeline currently **ignores a filter the account may already have set on the web**. Live probe on the test account returns `"viewingPreference":"all_messages"`, which pins the wire vocabulary for at least one value — probe the other three by setting them on the web and re-reading `GET /api/user` rather than guessing the snake_case forms. ⚠️ **Interaction with the following feed.** `Following Only` cannot be honoured until the backend feed exists (`P1-G` — re-verified broken 2026-09-05: `?scope=following` returns the identical unfiltered page). Until then the picker must either omit that option or select it and show the existing "coming soon" state honestly. Decide which in the PR, do not ship a silently-wrong feed. ## Division of labor **Kit** - [ ] Confirm `notificationTrayLimit` and `viewingPreference` are both accepted by `PATCH /api/user/update` (the verb fixed in PR #24) and that omitting them still never clobbers them. **Domain** - [ ] Add `viewingPreference` (typed enum + raw-string escape hatch) and `notificationTrayLimit` to `UserSettings` and its mapper. - [ ] Clamp `messagesPerPage` to 10...30 and `notificationTrayLimit` to 10...40 on both read and write. - [ ] Wire `viewingPreference` into `MessagesService.timeline` so the account's stored filter drives the default scope, with the `.following` short-circuit preserved until P1-G lands. **App** - [ ] `PreferencesView`: correct the stepper range; add the viewing-preference picker and the tray-limit stepper. - [ ] `NotificationsService` already reads a tray limit — connect the new setting to it (nothing sets it today). - [ ] The timeline's All/Mine/Following picker and the stored preference must not fight: decide whether the picker is a session override or writes the preference back, and document the choice in the view model. **Tests** - [ ] happy — each of the four viewing preferences round-trips - [ ] invalid — an unrecognised `viewingPreference` string falls back to All rather than throwing - [ ] upstream-failure — PATCH rejects → the pane restores the prior values and surfaces the error - [ ] boundary — 9/10/30/31 for page size and 9/10/40/41 for the tray limit ## Acceptance criteria - No value savable from macOS is unrepresentable on the web. - An account whose viewing preference was set on the web sees that filter honoured on launch in macOS. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G35**. Size S. | closed | bugparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/43 | Sep 7, 2026, 7:18 AM | Sep 13, 2026, 10:41 PM |
| 42 | bug(account): accountStatus is unmodelled — new/restricted/suspended accounts get opaque failures | ## Summary Every InterlinedList account carries an `accountStatus` that decides what it may do — independently of subscription tier and of email verification. The live API returns it on `GET /api/user`. **The macOS client does not decode it, does not model it, and shows nothing about it.** The practical result: a brand-new account (the state every new user starts in) has DMs, media upload, cross-posting, scheduled posts, and list/document/organization creation **locked**, plus an hourly and daily posting cap. In the macOS app those all present as unexplained server failures. A `restricted` or `suspended` account is read-only, and the app will keep offering write actions that cannot succeed. ## Evidence (live, 2026-09-07) `GET /api/user` (Bearer, test account) returns, among the other fields: ```json "accountStatus":"active" ``` The `User` schema in `GET /api/openapi.json` declares `accountStatus: string` (46 properties; ours decodes ~27 of them). `/help/account` documents the closed set and its consequences: | status | what it means | |---|---| | **New** (on probation) | Read/browse/follow/block/mute/report work. **Plain posting is rate-limited** (a few per hour, a small number per day). **Locked:** direct messages, image and video uploads, cross-posting, scheduled posts, and creating lists, documents, and organizations. Verifying email is the fastest way off it. | | **Active** | Normal. Everything works, subject only to subscription tier. | | **Restricted** | Temporarily read-only while under review. Cannot post, reply, react, follow, send messages, or create content. Appealable. | | **Suspended** | Same as restricted, applied by the team. Appealable. | | **Banned** | Closed; cannot sign in. | > *"When your account is new or locked, a banner at the top of your home page explains the current status and, where relevant, links you to verify your email or to contact support."* ## What we ship today ``` $ grep -rn "accountStatus" Packages/ App/ (no matches) ``` `UserDTO` (`Packages/InterlinedKit/Sources/InterlinedKit/DTOs/UserDTO.swift:77–103`) has no `accountStatus`. ## Division of labor **Kit** - [ ] Add `accountStatus: String?` to `UserDTO`. Keep it a raw string on the wire — an unknown future value must not fail the whole decode. - [ ] Contract test against the live shape. **Domain** - [ ] `AccountStatus` enum with the five documented cases plus an `.unknown(String)` escape hatch, and a mapper from the raw string (case-insensitive). - [ ] Add it to `CurrentUser`. - [ ] A capability projection: for a given status, which actions are permitted. Compose it with the entitlement gate and the email-verification gate so the composer asks one question, not three. **Precedence matters** — status is the hardest gate: a subscriber who is `restricted` still cannot post. - [ ] **Fail open on `.unknown`.** A status value we have never seen must behave as `.active`, so a server-side rename cannot brick a paying user's app. **App** - [ ] A status banner on the timeline root, mirroring the web: shown for `new`, `restricted`, `suspended`; hidden for `active`. Include the documented next step — verify email (link to Settings ▸ Account) for `new`, contact support for `restricted`/`suspended`. - [ ] Disable write affordances for `restricted`/`suspended` with the banner as the explanation, rather than letting each action fail on its own. - [ ] For `new`, disable exactly the documented locked set (DMs, media, cross-post, schedule, create list/doc/org) and leave plain posting enabled — it is rate-limited, not blocked. - [ ] `banned` cannot sign in, so it is a sign-in-failure path, not a banner. Confirm what the auth route returns for a banned account before writing that copy. **Tests** - [ ] happy — `active` → no banner, everything enabled - [ ] invalid — `restricted` → banner, write actions disabled, reads work - [ ] upstream-failure — field absent or an unrecognised value → treated as active, no banner, nothing disabled - [ ] boundary — `new` → plain posting enabled, the documented locked set disabled, verify-email CTA present ## Acceptance criteria - A new or restricted account is told why an action is unavailable before attempting it. - An unrecognised `accountStatus` never disables anything. - Full E2E gate green. ## Notes **New finding from the 2026-09-07 parity sweep** — not previously recorded in `work-consolidation.md`. It is the third leg of the "know the answer before asking the server" trio, alongside the entitlement-matrix issue and the email-verification issue; build it after those two so the composer has one place to consult. | closed | bugparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/42 | Sep 7, 2026, 7:17 AM | Sep 15, 2026, 11:23 AM |
| 41 | bug(compose): nothing gates on email verification, so posting fails at publish time (G38) | ## Summary The platform requires a **verified email** before a user can post a message or attach media. The macOS app decodes the flag, renders it once in Settings, and gates nothing on it — so an unverified user writes a whole message (or picks images) and only finds out at publish time, from a server error. ## The documented rule `/help/settings`: *"You must verify your email before posting messages or attaching media. If you did not receive the verification email, click **Resend verification email** in the Security section. You can resend once every 10 minutes."* `/help/messages`: *"You must have verified your email before you can post images or video."* and *"You must verify your email address before posting messages."* `/help/direct-messages`: *"You'll need a verified email address to send images"* in a DM. ## What we ship today - `CurrentUser.isEmailVerified` (`CurrentUser.swift:57`) is modelled and mapped (`Mappers.swift:39`). - Its **only** consumer is `AccountSettingsView.swift:114`, which displays it. - Nothing in the composer, the media picker, or the DM image path consults it. ## Backend constraint to resolve first `POST /api/auth/send-verification-email` is declared `x-auth-type: session` in the live OpenAPI spec — **a Bearer-only client cannot call it**. Verify this against the live API before building the resend button: ``` POST https://interlinedlist.com/api/auth/send-verification-email (Bearer) ``` If it genuinely rejects Bearer, the resend action must deep-link to the web Settings ▸ Security page instead of calling the route, and the backend ask should be filed with the other session-only-route asks. Do not ship a button that silently 401s. ## Division of labor **Kit** - [ ] Add the resend request builder **only if** the live probe shows Bearer is accepted. Otherwise skip Kit entirely and go with the deep-link. **Domain** - [ ] A single `PostingEligibility` (or equivalent) projection that answers "can this account post / attach media right now, and if not, why" from `CurrentUser`. Keep it separate from `EntitlementsService` — verification is not a subscription tier — but make the composer consult both. - [ ] Model the 10-minute resend cooldown so the UI can disable the button and say when it will be available again, rather than letting the user hammer a rate-limited route. **App** - [ ] Composer: when unverified, disable Post with an inline, non-modal explanation ("Verify your email before posting") plus the resend/deep-link action. Do not block typing — let the draft survive. - [ ] Media picker and DM image attach: same gate, same explanation. - [ ] Settings ▸ Account: put the resend action next to the existing verified indicator, with the cooldown surfaced. **Tests** - [ ] happy — verified account → composer posts normally - [ ] invalid — unverified → Post disabled, explanation shown, draft preserved - [ ] upstream-failure — resend returns 429 → cooldown message, button disabled, no crash - [ ] boundary — cooldown boundary (just under / just over 10 minutes) ## Acceptance criteria - An unverified account is told before composing, not after publishing. - The resend action either works over Bearer or opens the web page — never fails silently. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G38**. Size S. Related: the account-status gate issue covers the *other* reason a new account cannot post (rate limits on "new" accounts), which is a different mechanism with a different message. | closed | bugparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/41 | Sep 7, 2026, 7:15 AM | Sep 15, 2026, 11:23 AM |
| 40 | bug(entitlements): subscriber gating covers 3 of ~10 documented features (G37) | ## Summary The published Free/Subscriber matrix gates far more than the app knows about. `EntitlementsService` models **three** features; the platform gates **eight or so**. A Free account in the macOS app can walk all the way into a create flow and be rejected by the server with an opaque error, where the web would have shown an Upgrade prompt up front. ## The documented matrix (`/help/settings`, `/help/account`, re-read 2026-09-07) **Free** — post messages, follow, browse and read public lists + documents, join organizations, use tags, read templates, export data, and **accept** any share or email invite (accepting is always free, and the granted access survives a lapsed subscription). **Subscriber** — everything in Free, plus: - creating and managing **lists** and **list folders** - creating **documents** and **document templates** - creating **organizations** - **sharing** lists/documents with named people, and **inviting by email** - **share links** (creating; revoking is always free) - attaching **images and video** - **cross-posting** (Mastodon / Bluesky / LinkedIn / X) - **scheduling** messages - **AI** Two rules that are easy to get wrong and matter here: 1. **Lapse is create-only.** *"If your subscription lapses, your existing lists, documents, and organizations stay fully usable: you can still read and edit them. Only creating new … is subscriber-only."* So the gate is on **create**, never on read or edit. 2. **Adding rows is free.** *"Adding rows to an existing list is free, even without a subscription."* 3. **Revoking is free.** Removing someone's access and revoking a share link work with a lapsed subscription. ## What we ship today - `EntitlementsService.swift:87` — `canManageLists` returns the **M3 permissive default** (`listManagementOverride ?? true`) with a doc comment saying *"PLAN.md §6 M6 ships the real subscriber-driven gating logic"*. M6 shipped long ago. - `Feature` carries only `.mediaAttachments`, `.scheduledPosts`, `.crossPosting` (`EntitlementsService.swift:10–14`). - `DocumentsService` has **no** entitlement gate at all. - Organization create, share-link create, and email-invite create are ungated client-side (PR #13 notes say create paths are subscriber-gated — verify each actually is). ## Division of labor **Domain — `EntitlementsService`** - [ ] Extend `Feature` to the documented create-gated set: `.listCreation`, `.listFolderCreation`, `.documentCreation`, `.documentTemplateCreation`, `.organizationCreation`, `.sharingWithPeople`, `.emailInvites`, `.shareLinkCreation`, `.aiFeatures`, alongside the existing three. - [ ] Replace `canManageLists`'s permissive default with `customerStatus`-driven logic, and delete the stale PLAN.md comment. Keep the `init(customerStatus:canManageLists:)` override for tests. - [ ] Encode the **create-only** rule explicitly: name the cases so nobody can accidentally gate an edit or a read on them (`.listCreation`, not `.lists`). Add a doc comment stating that edit/read/row-add/revoke are never gated. **Domain — services** - [ ] `DocumentsService` — gate create (and create-from-template) on `.documentCreation`; leave update/read/delete ungated. - [ ] `ListsService` — gate list create on `.listCreation`; leave `createRow`/`updateRow` ungated (rows are free). - [ ] `OrganizationsService` — gate create on `.organizationCreation`; joining stays free. - [ ] `SharingService` — gate collaborator-add, role change, email invite, and share-link create; leave remove/revoke ungated. **App** - [ ] Every gated entry point disables (or badges) its control with an Upgrade affordance before the user does the work, matching the web's *"they appear locked with an Upgrade prompt"* pattern. Do not silently hide them — the web shows the control locked so the user learns the feature exists. - [ ] "Manage subscription" links out to the browser (see the subscription-surface issue) rather than offering any native purchase path. **Tests (per feature)** - [ ] happy — subscriber → allowed - [ ] invalid — free → denied, with the Upgrade affordance - [ ] upstream-failure — `customerStatus` missing/unknown → fail **open** for read/edit, closed for create; assert an unknown status never bricks an existing list - [ ] boundary — lapsed subscriber can still edit an existing list, add a row, remove a collaborator, and revoke a share link ## Acceptance criteria - A Free account never reaches a server 403 that the client could have predicted. - A lapsed subscriber can still read, edit, add rows, remove access, and revoke links. - `grep -rn "PLAN.md §6 M6" Packages/` returns nothing. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G37**. Size S–M. Pairs with the email-verification gate and the account-status gate — the three together are "know why the server will say no before asking". | closed | bugparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/40 | Sep 7, 2026, 7:15 AM | Sep 15, 2026, 11:23 AM |
| 39 | bug(ai): AI copy tells subscribers to add an API key that no longer exists (G30) | ## Summary AI (G15, PR #31) was built on the premise that the user brings their own OpenAI / Anthropic / Gemini key via the web Integrations page. **That premise is void.** AI is now powered by Claude and provided by InterlinedList as part of the subscription. Consequence: when `GET /api/ai/status` returns an empty `providers[]`, the shipped app tells a paying subscriber to go do something that is impossible. > `AI.swift:143` → `"Add your own AI provider key in Settings on interlinedlist.com to use AI features."` There is no such setting on the live Integrations page. ## Evidence (live, 2026-09-07) `GET /api/ai/status` (Bearer, test account): ```json {"subscriber":true,"providers":["anthropic"], "defaultModels":{"anthropic":"claude-sonnet-5","openai":"gpt-4.1-mini","gemini":"gemini-2.0-flash"}, "quota":{"usedToday":8,"dailyLimit":50,"remaining":42}} ``` `/help/ai`: *"AI writing assistance … is a **Subscriber** feature, powered by Claude and included with your subscription, so there is no API key to set up and no separate AI bill."* … *"If you are subscribed and the AI controls are missing, the site's AI provider is temporarily unconfigured. The features hide themselves in that case rather than failing partway through an action."* `/help/settings`: *"There is nothing to set up. AI is powered by Claude and provided by InterlinedList. You do not supply an API key, and there is no separate AI bill."* … *"AI actions are capped at 50 per day per account. A preview and its confirmed action each count."* ## What is actually wrong The **gating logic is already correct** — `AIAvailability.isAvailable` keys off `subscriber` + non-empty `providers`. This is copy, comments, and one behavioural rule (hide vs. explain). ## Division of labor **Domain** - [ ] `AI.swift:143` — replace the unavailable-reason copy. When `subscriber == true` and `providers` is empty, the reason is *the site's AI provider*, not the user's setup. Suggested: "AI is temporarily unavailable. InterlinedList's AI provider is not responding right now — try again later." When `subscriber == false`, the reason stays a subscription prompt. - [ ] Split the two cases explicitly rather than returning one string for both — a Free user and a subscriber during an outage need different sentences. - [ ] `AI.swift:112` — the doc comment "Provider slugs the account has configured" is wrong; these are the platform's configured providers. - [ ] `AI.swift:167` — "the cost of the user's own provider key" is wrong; the cost lands on InterlinedList's account and is bounded by the daily quota. **App** - [ ] `AIAssistantViewModel.swift:9` — stale BYO-key comment. - [ ] Follow the documented behaviour: when AI is unavailable, **hide** the composer AI control and the Powered Template / Powered Document entry points rather than showing a disabled control with an explanation. Free accounts "do not see the AI controls at all". - [ ] Surface the daily quota where it helps: `quota.remaining` is already decoded. When remaining is 0, say so plainly ("You've used all 50 AI actions for today") instead of showing a generic failure — and note that a preview and its confirm each cost one. **Docs** - [ ] `docs/spikes/ai-materialize-live-shapes.md` — strike the two bring-your-own-key paragraphs. **Tests** - [ ] happy — subscriber + `providers:["anthropic"]` → available, controls visible - [ ] invalid — non-subscriber → controls hidden, subscription copy - [ ] upstream-failure — subscriber + `providers:[]` → controls hidden, provider-outage copy, and the string does **not** mention an API key - [ ] boundary — `quota.remaining == 0` → distinct quota-exhausted copy ## Acceptance criteria - No user-facing string anywhere in the app instructs anyone to add an AI provider key. - A subscriber during a provider outage sees AI hidden, not a broken instruction. - Full E2E gate green. ## Notes `work-consolidation.md` tracks this as **G30**. Size XS — copy plus one visibility rule. | closed | bugparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/39 | Sep 7, 2026, 7:14 AM | Sep 15, 2026, 11:27 AM |
| 38 | bug(timeline): the Link action copies a login-walled URL, and Copy embed code is missing | ## Summary The "Link" action added in PR #32 copies `https://interlinedlist.com/messages/<id>`. That is **not** the web app's shareable permalink — it is an authenticated route that redirects anonymous visitors to `/login`. Everyone a user shares a link with lands on a sign-in page instead of the message. The web's shareable address is `/user/<username>/status/<id>`, and its Share menu offers **two** actions, of which we ship neither correctly: - **Copy link** → the public permalink - **Get embed code** → an HTML snippet for pasting into a blog/web page ## Evidence (live, 2026-09-07) ``` GET https://interlinedlist.com/messages/d05faf17-… → 200, final URL = /login GET https://interlinedlist.com/user/hubcity/status/d05f… → 200, serves the message (no auth) ``` `/help/messages`: *"Every message has its own permanent page at an address like `/user/<username>/status/<id>` … Each message's action bar includes a `Share` button (the link icon) with two options: **Copy link** … **Get embed code** … Only **public** messages can be linked to or embedded for other people."* ## What we ship today - `Packages/InterlinedDomain/Sources/InterlinedDomain/Models/MessagePermalink.swift` — builds `<base>/messages/<id>`, documented as *"matching the web app's own route"*. It does not. - Three call sites consume it: the message row's Link action, the "Push & Comment" body, and `CreateIssueFromMessageViewModel`'s issue body — so a wrong URL is currently pasted into **GitHub issues** as well. ## Division of labor **Domain — `MessagePermalink`** - [ ] Change the shape to `<base>/user/<username>/status/<id>`. The author handle becomes a required input, so the API becomes `url(forMessageID:authorUsername:base:)`. - [ ] Percent-encode the username with the same path-segment set already used for the id (drop `/` from `urlPathAllowed`). - [ ] Return `nil` when the author handle is missing or blank, so the UI hides the affordance rather than offering a broken link. - [ ] Add `embedHTML(forMessageID:authorUsername:base:)` **only after** the web's snippet is captured verbatim — do not invent the markup. Probe: open a public message on the web, click Share ▸ Get embed code, paste the clipboard into this thread, then implement to match. **Domain — `Message`** - [ ] Confirm the author username is present on `Message` at every call site (timeline row, detail view, push-and-comment, create-issue). If a projection drops it, carry it. **App** - [ ] `MessageRowActions` / `MessageRowView`: the Link action copies the corrected permalink; add a sibling **Copy embed code** action once the snippet is known. - [ ] Hide/disable both actions for a **private** message — the web states the link and embed only work for the owner. A copy action producing a link nobody else can open is worse than no action. - [ ] `CreateIssueFromMessageViewModel` and the Push & Comment body pick up the corrected URL for free; assert it in their tests. **Tests (BDD quartet)** - [ ] happy — public message with a handle → `/user/<handle>/status/<id>` - [ ] invalid — blank/missing handle → `nil`, affordance hidden - [ ] upstream-failure — n/a (pure projection); instead assert the private-message path offers no link - [ ] boundary — handle and id needing percent-encoding; a `base` with a trailing slash ## Acceptance criteria - Copying a link from a public message and opening it in a private browser window renders the message, not `/login`. - The URL in a GitHub issue created from a message resolves for someone who is not signed in. - Private messages expose no Link/Embed action. - Full E2E gate green (`.claude/skills/swift-engineer/assets/e2e-gate-checklist.md`). ## Notes Found during the 2026-09-07 parity sweep. Nothing regressed — the action shipped with the wrong shape in PR #32 — but it is user-visible on `dev` today, so it is first in the queue. | closed | bugparity | Empty | https://github.com/CompositeCode/interlinedlist-macos-native/issues/38 | Sep 7, 2026, 7:11 AM | Sep 13, 2026, 10:41 PM |
| 29 | Create a Crash Report | Create a crash report in the MacOS Native app that automatically logs an issue to Github (with the setting to "help development) in the Settings screen. This toggle will need added to the settings options specifically for the MacOS Native app. When it is enabled, Whenever a crash or error occurs, it will post an issue on Github, with a confirmation by the user to do so. If they confirm it will post a bug report to Github for the repo that these issues are in. | closed | enhancement | Adron | https://github.com/CompositeCode/interlinedlist-macos-native/issues/29 | Sep 7, 2026, 5:14 AM | Sep 7, 2026, 7:04 AM |
| 28 | "New Folder" Still shows up in Lists | The "New Folder" at the top of the Github backed lists still shows up in the native app. This should show up as there is not a "folders" feature to the lists. | closed | bug | Adron | https://github.com/CompositeCode/interlinedlist-macos-native/issues/28 | Sep 7, 2026, 5:11 AM | Sep 7, 2026, 7:04 AM |
| 27 | Message Features Need Implemented | It seems like the standard features on the website aren’t implemented for the messages on the MacOS Native app. Reply I Dig! Link (i.e. the link for the specific post) Push Message Push & Comment All of these things need implemented. | closed | Empty | Adron | https://github.com/CompositeCode/interlinedlist-macos-native/issues/27 | Sep 7, 2026, 5:10 AM | Sep 7, 2026, 7:04 AM |