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.EditIssueon EITHER binding. Field validation runs FIRST, unchanged and before any binding check: the raw patch decodes intomap[string]json.RawMessage,patchStringper 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 atracker.IssueEditfrom the set fields (&Valuewhen set; a non-nil emptyBodyclears the body, which already works through the seam) and ridesEditIssue. 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
CloseIssuebut no reopen, so it cannot express the open↔closed transition or own theclosed_atstamp. A state change therefore stays store-routed on builtin — title/body ride along in the onestore.UpdateIssuetransaction, verbatim as before the split — and on a forge binding it is refused with a NEW pinned message (forgeStateMessage, sibling offorgeTrackerMessage): "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.
EditIssueanswers 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 atk.Issueread and answers that (trackerIssueDetailJSON). On builtin the JSON is byte-identical to the pre-seamstoreIssueDetailJSON(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.changedfires ONLY for a builtin-bound mutation — the identical convention agentapi'spublishIssueChangedalready 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.