Skip to content

Operator issue edit routes title/body through Tracker.EditIssue on every binding — state stays builtin-only behind a pinned 400

Issue #168 (2026-07-17): the operator PATCH /api/v1/repos/{id}/issues/{n} was gated requireBuiltinTracker, so a forge-bound repo could not edit an issue's title or body from the web UI — it answered the same 409 that ADR-0009/0014 pin for builtin-only mutations. But #167 landed Tracker.EditIssue (the labctl issue edit seam for agents): a binding-agnostic title/body patch that already flows to whichever backend the repo's binding names. The operator PATCH was the last edit path still refusing the forge; this slice routes it through the same seam, and the interesting half is deciding what does NOT ride it — state — and how it is refused.

The decisions, pinned:

  • Operator title/body edits flow through Tracker.EditIssue on EITHER binding. Field validation runs FIRST, unchanged and before any binding check: the raw patch decodes into map[string]json.RawMessage, patchString per key, a whitespace-only title, a non-enum state, and an unknown key each 400 with their existing pinned wording — so an invalid patch is refused identically whatever the binding (an {"state":"parked"} on a forge repo answers "state must be open or closed", not the forge message). After validation, a title/body-only patch — including the legal empty-patch existence-verifying no-op — builds a tracker.IssueEdit from the set fields (&Value when set; a non-nil empty Body clears the body, which already works through the seam) and rides EditIssue. A forge-bound repo now edits its issue on the forge: the exact write ADR-0014 opened to agents, opened to the operator too.

  • State stays builtin-only, behind a pinned 400 — not the 409 sibling. The seam carries CloseIssue but no reopen, so it cannot express the open↔closed transition or own the closed_at stamp. A state change therefore stays store-routed on builtin — title/body ride along in the one store.UpdateIssue transaction, verbatim as before the split — and on a forge binding it is refused with a NEW pinned message (forgeStateMessage, sibling of forgeTrackerMessage): "repository uses a forge tracker; issue state is managed on the forge". It is a 400, not a 409: the request is understood and well-formed, it just names a field THIS binding cannot patch — distinct from the 409 that means "this whole operation (create, label set, comment) is builtin-only." The missing reopen op is a known, deliberately-unfiled follow-up; this ADR pins the gap.

  • The PATCH keeps its DETAIL response shape via a follow-up read. EditIssue answers the LIST shape by seam contract ("an edit is a write, not a thread read"), but this endpoint has always returned the full comment thread, so it follows a successful edit with a tk.Issue read and answers that (trackerIssueDetailJSON). On builtin the JSON is byte-identical to the pre-seam storeIssueDetailJSON (the builtin tracker reports the same author-kind comment vocabulary) — builtin behaviour is unchanged in the wire, which is the "builtin unchanged" proof.

  • A forge-bound edit publishes no bus event; last write wins. issue.changed fires ONLY for a builtin-bound mutation — the identical convention agentapi's publishIssueChanged already draws, since forge state lives on the forge and lab's SSE clients drive off builtin state. There is no concurrency token: an edit is last-write-wins, mirroring the seam contract and the agent PATCH.

Status

Accepted. Settled via issue #168. Revises the "operator mutations remain builtin-only" sentence of ADR-0014 (which gains a status pointer here) — title/body edits now flow through the tracker seam on every binding; everything else stands (issue create, state change, label set, and comment create stay builtin-only with the pinned 409). It narrows the "Operator mutations are builtin-only" clause of ADR-0009 the same way, and that ADR gains a matching pointer. Builds directly on ADR-0009's Tracker seam and the EditIssue op #167 added for agents; no schema change, no new migration.