Skip to content

Commit 73b4765

Browse files
khaliqgantclaude
andauthored
fix(linear): allowlist mutable IssueUpdateInput fields, drop synced-read fields (#36)
When a user edited a synced /linear/issues/<uuid>.json file in place — the canonical workflow for the demo's bidirectional flow — buildIssueUpdate forwarded every field as IssueUpdateInput. The path-mapper's read shape includes denormalized fields that Linear's read API returns but IssueUpdateInput does NOT accept (state_name, assignee_name, priority_label, created_at, updated_at, _connection, _webhook, descriptionData on some read paths). Linear rejected with: Field "state_name" is not defined by type "IssueUpdateInput". Did you mean "stateId"? The denylist approach (drop only id/identifier/createdAt/...) was too narrow — it missed every snake_case denormalized field, and would miss any new field the path-mapper ever adds. Flip to an explicit allowlist matching IssueUpdateInput's actual schema, mirroring how buildIssueCreate already handles IssueCreateInput. Anything not in the allowlist is silently dropped; only mutable fields reach the mutation. Caught and reproduced on op_31 of workspace rw_517d60b6 during the demo verification immediately after cloud#467 deployed. Regression test pins the synced-read shape that hit op_31. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 56e5e1c commit 73b4765

2 files changed

Lines changed: 87 additions & 23 deletions

File tree

packages/linear/src/writeback.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,47 @@ describe('linear writeback', () => {
106106
});
107107
});
108108

109+
it('drops denormalized synced-read fields that IssueUpdateInput does not accept', () => {
110+
// Pins the bug surfaced on op_31 in workspace rw_517d60b6: the synced
111+
// file the path-mapper wrote on the read side included Linear's
112+
// denormalized fields (state_name, assignee_name, priority_label,
113+
// _connection, _webhook, descriptionData, …). When a user edited the
114+
// file in place keeping the full envelope and just changed the title,
115+
// the writeback forwarded every field as IssueUpdateInput and Linear
116+
// rejected with `Field "state_name" is not defined by type
117+
// "IssueUpdateInput". Did you mean "stateId"?`.
118+
//
119+
// The fix: explicit allowlist matching IssueUpdateInput. Anything not
120+
// in the schema is silently dropped; only mutable fields go through.
121+
const req = resolveWritebackRequest(
122+
`/linear/issues/${PAGE_UUID}.json`,
123+
JSON.stringify({
124+
id: PAGE_UUID,
125+
identifier: 'PROJ-441',
126+
title: 'edited title',
127+
description: 'edited description',
128+
// Denormalized read-only fields the writeback must NOT forward:
129+
state_name: 'Backlog',
130+
assignee_name: null,
131+
priority_label: 'No priority',
132+
created_at: '2026-04-03T18:38:27.932Z',
133+
updated_at: '2026-04-03T18:38:28.177Z',
134+
url: 'https://linear.app/x/issue/PROJ-441',
135+
// Read-side metadata Linear's webhook normalizer emits:
136+
_connection: { provider: 'linear' },
137+
_webhook: { action: 'update' },
138+
descriptionData: '{"type":"doc"}',
139+
}),
140+
);
141+
const variables = req.body.variables as { id: string; input: Record<string, unknown> };
142+
assert.strictEqual(variables.id, PAGE_UUID);
143+
assert.deepStrictEqual(variables.input, {
144+
title: 'edited title',
145+
description: 'edited description',
146+
descriptionData: '{"type":"doc"}',
147+
});
148+
});
149+
109150
it('unwraps the synced envelope written by LinearAdapter.renderContent', () => {
110151
// This is the exact shape an agent sees when it reads a synced
111152
// /linear/issues/<id>.json file produced by LinearAdapter.renderContent.

packages/linear/src/writeback.ts

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -208,26 +208,10 @@ function buildIssueCreate(content: string): LinearWritebackRequest {
208208
};
209209
}
210210

211-
/**
212-
* Server-managed fields that must never be forwarded as part of an
213-
* `IssueUpdateInput`. Linear rejects updates that try to mutate these.
214-
*/
215-
const ISSUE_UPDATE_DENYLIST = new Set([
216-
'id',
217-
'identifier',
218-
'createdAt',
219-
'updatedAt',
220-
'archivedAt',
221-
'url',
222-
'team',
223-
'creator',
224-
'parent',
225-
]);
226-
227211
/**
228212
* Envelope marker fields written by `LinearAdapter.renderContent()`. When the
229213
* payload presents itself as the synced envelope (rather than a bare update
230-
* input), unwrap to the inner `payload` before applying the denylist.
214+
* input), unwrap to the inner `payload` before applying the allowlist.
231215
*
232216
* Without this step, a round-tripped synced file would forward `provider`,
233217
* `workspaceId`, `objectType`, etc. into the GraphQL mutation and Linear
@@ -240,16 +224,52 @@ function looksLikeSyncedEnvelope(payload: Record<string, unknown>): boolean {
240224
return ENVELOPE_MARKER_KEYS.some((key) => key in payload);
241225
}
242226

227+
/**
228+
* Fields Linear's `IssueUpdateInput` accepts. We use an explicit allowlist
229+
* (rather than a denylist) so that synced-file fields the read API returns
230+
* — `state_name`, `assignee_name`, `priority_label`, `created_at`, `_webhook`,
231+
* etc. — get silently dropped instead of being forwarded into the mutation
232+
* and rejected with `Field "X" is not defined by type "IssueUpdateInput"`.
233+
*
234+
* Mirrors the field set buildIssueCreate already accepts (which is itself a
235+
* subset of `IssueCreateInput`), plus update-specific fields that are
236+
* commonly edited in a synced-file workflow:
237+
* - `subscriberIds` — change watchers
238+
* - `sortOrder` — manual reordering
239+
* - `descriptionData` — Linear's prosemirror-doc form of `description`
240+
*
241+
* If you need to set a field not in this list (e.g. `addedLabelIds` /
242+
* `removedLabelIds` for incremental label changes), add it here and add a
243+
* regression test in `__tests__/writeback-allowlist.test.ts`.
244+
*/
245+
const ISSUE_UPDATE_ALLOWLIST: ReadonlySet<string> = new Set([
246+
'title',
247+
'description',
248+
'descriptionData',
249+
'priority',
250+
'assigneeId',
251+
'stateId',
252+
'projectId',
253+
'cycleId',
254+
'labelIds',
255+
'dueDate',
256+
'estimate',
257+
'parentId',
258+
'subscriberIds',
259+
'sortOrder',
260+
]);
261+
243262
/**
244263
* Build an `issueUpdate` mutation request for the writeback engine.
245264
*
246265
* Accepts two payload shapes:
247266
* - the full synced envelope produced by `LinearAdapter.renderContent()`,
248267
* with editable fields under `payload`. We unwrap automatically.
249-
* - a bare update input where the top-level object IS the input (the form
250-
* a hand-written workflow or specialist agent typically produces).
251-
*
252-
* Server-managed fields are stripped in either case.
268+
* - a bare update input where the top-level object IS the input — covers
269+
* both hand-written workflow payloads and edits to the synced-file
270+
* denormalized read (where the user keeps the full `{state_name,
271+
* assignee_name, ...}` envelope and just changes a field). Synced-only
272+
* fields are silently dropped via the allowlist.
253273
*/
254274
function buildIssueUpdate(issueId: string, content: string): LinearWritebackRequest {
255275
const parsed = parseJsonObject(content);
@@ -259,11 +279,14 @@ function buildIssueUpdate(issueId: string, content: string): LinearWritebackRequ
259279

260280
const input: Record<string, unknown> = {};
261281
for (const [key, value] of Object.entries(source)) {
262-
if (ISSUE_UPDATE_DENYLIST.has(key)) continue;
282+
if (!ISSUE_UPDATE_ALLOWLIST.has(key)) continue;
263283
input[key] = value;
264284
}
265285
if (Object.keys(input).length === 0) {
266-
throw new Error('issues/<id>.json update writeback requires at least one mutable field');
286+
throw new Error(
287+
'issues/<id>.json update writeback requires at least one mutable field ' +
288+
'(see ISSUE_UPDATE_ALLOWLIST in @relayfile/adapter-linear/writeback for the accepted set)',
289+
);
267290
}
268291

269292
return {

0 commit comments

Comments
 (0)