Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
82 KiB
status, created, owner
| status | created | owner |
|---|---|---|
| specified | 2026-08-19 | kandev |
Office Task Content Editing
Why
The Office task detail page (/office/tasks/[id], simple pane) renders the task title and
description as static text. Every other property on the page (status, priority, assignee,
project, parent, blockers, reviewers, approvers, labels) is editable inline. The two fields that
carry the most meaning are the two a user cannot change.
The description is not decoration: it is the brief the assigned agent works from. A typo in the
title, a missing constraint in the brief, or a scope note that arrived after the task was filed
currently has exactly one remedy: leave the Office UI, open /t/<id>, and edit from the Kanban
surface. Users read the absence as a bug. It is not: docs/specs/office/tasks.md:58 enumerates the
editable properties and omits both fields, and the shipped code implements that list faithfully.
This is a spec gap, and this document closes it.
Nothing in the backend blocks it. PATCH /api/v1/tasks/:id has accepted title and description
since before Office existed, both surfaces read the same tasks row, and the resulting
task.updated event already drives the Office detail page's live refresh.
What
A. Scope of the affordance
- The title and description of a task are editable in place on the Office task detail page's simple pane. No dialog, no navigation, no separate edit mode for the page as a whole.
- The affordance is available on every task the detail page can render, in every task status
(including
doneandcancelled) and in every session state (includingRUNNING). No new permission, ownership, or lifecycle gate is introduced. This matches the sidebar property pickers, which are likewise ungated. - Archived tasks are not a special case here. The Kanban title editor
(
apps/web/components/task/task-top-bar-title.tsx) gates renaming on anisArchivedprop, and parts of that component are reused for the title editor. That gate is deliberately NOT ported. The Office detail page does not render an archived state, does not receive an equivalent flag, andService.UpdateTaskenforces no archived precondition of its own, so porting the gate would introduce a condition that can never be true and would read as dead code. AC-30 and AC-47 govern. - Only the simple pane is in scope. The advanced / dockview mode is unchanged.
B. Title editing (inline, Enter to commit)
- The title renders as a text element that the user activates by double-click, or by focusing it and pressing Enter. Activation replaces it with a single-line text input seeded with the current title, focused, with its contents selected.
- Enter commits. Escape cancels and discards the draft. Blur cancels and discards the draft.
- The title baseline. When the title editor opens it captures the displayed title at that moment as its frozen baseline, frozen for the lifetime of the editor. Wherever this document says "the stored title" while a title editor is open (AC-4's restore target, AC-8's equality test) it means that frozen baseline, never a canonical value that arrived afterwards. This is the same construct section C defines for the description and it exists for the same reason: while the editor is open the displayed title is guarded (section E), so a remote edit must not silently change what Escape restores or what counts as "unchanged". The canonical value that arrived meanwhile is not lost. It is applied when the editor closes, under the deferred-apply rule in section E. AC-51 governs.
- Commit is optimistic: the new title renders immediately, the request is issued, and on failure the previous title is restored and the error surfaces as a toast. Which value "the previous title" means when commits overlap is defined in section F under "Rollback ordering".
- The value that is sent and the value that is optimistically rendered are both the whitespace-trimmed draft, so the visible title always equals the value the server will persist. The server trims too; the client trims first so the two never disagree, even briefly.
- The input is hard-clamped to 60 characters as the user types, counted the same way the
backend counts (Unicode code points,
TaskTitleMaxLength = 60inapps/backend/internal/task/service/task_title.go). The caret must not jump to the end of the field when a keystroke is clamped away. - Paste is clamped identically to typing: the pasted text is inserted at the caret, the result is clamped to the first 60 code points, and the caret stays at the end of the inserted run (or at the clamp boundary when the insertion is cut short). Paste is not a separate code path with separate truncation rules.
- A commit whose trimmed draft is empty is refused client-side: the editor stays open, the field is marked invalid, and no request is issued. The draft is never silently discarded.
- A commit whose trimmed draft equals the current title issues no request and closes the editor.
- IME composition must not commit. An
Enterthat the IME is using to accept a candidate is consumed by the IME, not by the editor.
C. Description editing (explicit Save and Cancel)
- The description renders as static text that the user activates by clicking it. Activation replaces it with a multi-line textarea seeded with the current description, focused, caret at the end.
- When the task has no description, the page renders a persistent placeholder affordance in the description's position that activates the same editor. An empty description must never leave the user with nothing to click.
- "No description" is defined as: the stored description, after trimming, is the empty string.
This single definition covers
null,"", and a field omitted from the DTO (the Office task DTO serialisesdescriptionwithomitempty, so all three reach the client as the same absence). There is no fourth state and no visible difference between them. - The editor baseline. When the editor opens it captures the current stored description as its baseline, and that baseline is frozen for the lifetime of the editor. Every draft-versus-stored comparison in this document (Save enabled/disabled, Cancel confirm/no-confirm, Escape inert/closing) compares the trimmed draft against the frozen baseline, never against a value that may have changed underneath. A successful save replaces the baseline with the value that was saved. Nothing else replaces it. The rationale is in section F under "Late-arriving refresh": without a frozen baseline, a remote edit by another client silently flips Save, Cancel, and Escape behaviour while the user is typing, with no action of their own.
- Comparisons use the trimmed draft, and the value sent is the trimmed draft. A whitespace-only draft is therefore equal to an empty one, so a whitespace-only edit on an empty description leaves Save disabled rather than issuing a save that the server would silently reduce to a no-op.
- The editor commits only on an explicit Save. Cancel discards. Blur does not commit and does not close the editor: the draft survives clicking elsewhere on the page.
- Escape discards the draft and closes the editor when the draft is unchanged from the baseline. When the draft is changed, Escape does nothing; the user must choose Save or Cancel.
- Cancel with a changed draft requires a confirmation before the draft is discarded. Cancel with an unchanged draft closes immediately with no prompt.
- Save is disabled while the trimmed draft equals the baseline, and while a save is in flight.
- Save is not optimistic: the editor stays open and shows a saving state until the request resolves. The non-optimism is end to end, not just on this page: the description's Office task store patch, which drives card previews in list and board views, is also applied only on success (section E, AC-56). A card preview that updated before the save resolved would contradict this bullet on a different surface. On failure the editor stays open with the draft intact and the error surfaces as a toast, so a rejected save never destroys typing.
- While a save is in flight the textarea stays editable; Save and Cancel are both disabled and Escape does nothing. Disabling the textarea instead would silently swallow keystrokes the user has already begun typing.
- On a successful save, if the current trimmed draft still equals the text that was submitted, the editor closes and the saved value renders. If the user edited the draft while the save was in flight, the editor stays open with the newer draft intact and the baseline becomes the value that was just saved, so Save re-enables for the newer text. A successful save must never discard typing that the user did after pressing it. This is the same principle as the failure path, applied to the success path.
- The description is plain text, rendered with preserved whitespace. This feature introduces no Markdown rendering and no rich-text editing.
- There is no length limit on the description, client-side or server-side.
Why the two fields differ. The title is one line, bounded at 60 characters, and cheap to retype
if a commit is lost; optimistic-with-discard matches the pickers and the existing Kanban rename. The
description is unbounded prose that is also the agent's brief: Kandev stores no description version
history, so a save that fires by accident, or a draft dropped on blur, is unrecoverable. The
industry split follows exactly that line. Linear and Notion autosave and have undo; Jira and GitHub
use explicit buttons and do not. Kandev is in the second group. project-header.tsx already gates a
Save affordance on a dirty long-form Office textarea, which is the precedent for requiring an
explicit commit; it is not a precedent for this feature's control layout, which section H
specifies directly.
D. What an edit does NOT do
- A title or description edit does not wake, interrupt, or re-prompt the assigned agent, and
does not queue a run. Verified:
Service.UpdateTaskpublishestask.updated(service_tasks.go:1607); the only Office subscriber for that event ishandleTaskUpdated(internal/office/service/event_subscribers.go:749), which readsassignee_agent_profile_idoff the payload;publishTaskEventNow(service_events.go:370-436) never puts that field on atask.updatedpayload. With an empty profile id andfallbackToStoredRunner = false,queueTaskAssignedRunreturns without queuing. Note thatTaskUpdatedDatadeclares that field even though this publisher never sets it, so the invariant is one added line away from breaking. AC-31 is therefore a tested contract, not a standing code-reading. - Consequently, a running agent continues on the brief it was launched with. An edited description is picked up at the agent's next wakeup, produced by the existing triggers (a comment, an assignment change, a status change), not by the edit.
- Because this surprises users, the description editor shows a non-blocking inline note while at
least one session on the task is in state
RUNNING, stating that the change applies at the agent's next wakeup. The note never blocks Save. - An edit does not change task status, does not move the task between workflow steps, and records no approval decision.
E. Persistence and live update
- Both fields are written through the existing
PATCH /api/v1/tasks/:idendpoint (frontend:updateTask()inapps/web/lib/api/domains/kanban-api.ts). The Office endpointPATCH /api/v1/office/tasks/:idis not extended, and its documented field list indocs/specs/office/tasks.md:229remains correct. - The request carries only the field being edited. A title commit sends
{ title }; a description save sends{ description }. - The server trims leading and trailing whitespace from both fields before persisting
(
task_http_handlers.go,httpUpdateTask). The client trims before sending as well (sections B and C), so the rendered value equals the stored value at every moment, not merely after a round-trip. - After a successful write the backend publishes
task.updated, which the Office WS broadcaster re-emits asoffice.task.updated, which triggerstriggerRefetch("task:<id>")and re-fetches the canonical task DTO. Other open clients converge without polling. No new event, subject, or subscriber is introduced. - Canonical value versus displayed value. These are two different things and the distinction carries the whole of this section. The canonical value of a field is the value this client last learned the server holds. The displayed value is what renders on the page, what seeds an editor, and what the Office task store entry carries for list and board cards. They are usually equal. They diverge exactly while a local edit is in flight or an editor is open, and the rules below say how they re-converge. Nothing in this feature ever discards a canonical value in order to protect a draft; it defers applying it.
- What records a canonical value: the complete list. Exactly three events record a canonical
value for a field, and nothing else does.
- The initial task load for the page records both fields (AC-59).
- A refetch resolving, unless the response is discarded by one of the two ordering rules below (AC-49).
- A successful write resolving, which records the value that request persisted for the field
it wrote, read from the response body.
updateTask()already returns the updated task (apps/web/lib/api/domains/kanban-api.ts), so this costs no extra request. The recording happens before the write releases that field's guard (AC-58). Every recording ofdescriptionis normalised to the empty string first.undefined,null, and a field omitted from the payload all record as"". This is not a tidiness rule and it is not optional: the two sources genuinely disagree in their types. The Office task DTO declaresdescriptionoptional (OfficeTaskinapps/web/lib/state/slices/office/types.ts, and the Go side serialises it withomitempty), so a load or refetch recording can carryundefined; a write response carriesTask.description, which is a required string (apps/web/lib/types/http.ts), so it records"". Without normalisation those two are unequal, AC-50 applies a canonical value "if the two differ", and every guard release on a task with no description would fire a spurious apply forever. Normalising at the recording site makes the canonical value, the displayed value and the store entry use the one definition section C already gives for "no description", so all three agree.titleneeds no equivalent rule and must not be given one by symmetry: both DTOs declare it a required string. AC-71 governs. Recording is otherwise unconditional: it is never suppressed by an open editor or an in-flight write. Only the application of a canonical value to the displayed value is guarded (AC-49). The third trigger is not bookkeeping. Without it the client's own successful edit is never recorded, so the deferred apply defined below would land the pre-write value the instant the guard released and visibly revert a save that had just succeeded.
- Stale responses are discarded, not recorded. Each fetch records, per field, the client's
write generation for that field at the moment the request is issued. A response is stale for a
field if this client committed a write to that field after the request was issued, and a stale
response is discarded for that field only: it is neither recorded as canonical nor applied.
Other fields of the same response are unaffected. This is what stops a refetch begun before a
save from resolving afterwards and reinstating the pre-save text (AC-52). Staleness is judged by
request-issue order, not by comparing values, so a response that happens to carry a third party's
newer value but was requested before this client's write is discarded too. That is deliberate and
costs nothing durable: the third party's write published its own
task.updated, so a fresh refetch is already on its way. - Recordings are ordered by
tasks.updated_at, and a strictly older recording is discarded. A candidate recording is accepted only if theupdated_atits payload carries for the task is not older than theupdated_atcarried by the field's currently recorded canonical value. A strictly older candidate is discarded for that field: neither recorded nor applied (AC-60).updated_atis the right column precisely because section F already establishes that it is stamped inside the write transaction, so its order matches true serialization order.- This applies to all three record triggers, a write's own response (AC-58) included, not only
to refetches. That is load-bearing rather than tidiness: when two title commits overlap and the
server commits them in the opposite order to the one the client issued them in, the two write
responses carry the row's two successive
updated_atvalues, so ordering by that column records whichever write the row actually kept. Recording by arrival order instead would leave the canonical value at the losing write. This is a second, independent reason AC-54 converges. - Tiebreak, named. When two instants are exactly equal, the later-arriving recording wins,
ordered by this client's request-issue sequence for the task (AC-63). That sequence is a single
per-task counter, incremented on every request this client issues for the task, fetch or
write, so every candidate recording carries one. Equal
updated_atmeans the same row version, so the field values agree and the choice is not observable; the tiebreak exists so the rule is total instead of leaving Build to invent one, and it does real work only where a second-resolution timestamp format is in play. - Compare parsed instants, never raw strings, and note that the two formats are further apart
than they look. The three record triggers do not agree on serialization, and the disagreement
is not a fractional-second detail. It is two different date syntaxes.
- The Office task DTO feeds two of the three triggers, the initial load (AC-59) and every
refetch (AC-49). It passes the SQLite
updated_atcolumn straight through as a raw string (internal/office/repository/sqlite/tasks.go,TaskSearchResult.UpdatedAt string). What is in that column is written byupdateTaskTx, which bindstask.UpdatedAtas a Gotime.Timetomattn/go-sqlite3; that driver's default bind layout is2006-01-02 15:04:05.999999999-07:00: space-separated, with a numeric offset (+00:00), never aT, never aZ, and not ISO-8601. This is established in this repository, not inferred:internal/analytics/repository/sqlite/stats.gocarries a comment recording the bug it already caused ("RFC3339 put a 'T' where every stored value has a space, and 'T' sorts above ' ', so rows silently lost their first day"); the same layout is referenced ininternal/office/repository/sqlite/failure.goandinternal/task/repository/sqlite/worktree_ownership_normalize.go; andinternal/office/dashboard/service_inbox.go'sparsedTaskUpdatedAtneeds four fallback layouts and still falls back totime.Now()when all four miss. - A write's own response (AC-58) carries
TaskDTO.UpdatedAt time.Time(internal/task/dto/dto.go), marshalled byencoding/json, which is RFC3339Nano: theT/Zform. Thetask.updatedWS payload uses the same form (internal/task/service/service_events.go), though that payload is not a record trigger; it is named here only so a builder does not add it as a fourth one. So a comparison between a refetch recording and a write recording is a comparison between2026-08-20 14:30:05.123456789+00:00and2026-08-20T14:30:05.123456789Z. As text they do not order at all; the space sorts belowT, so the raw-string comparison is not merely imprecise, it is systematically wrong in one direction. The trailing-zero hazard is real too, and survives within the RFC3339Nano form alone, because RFC3339Nano strips trailing zeros from the fractional second, so...:05.1Zand...:05.05Zdo not order correctly as text either. The requirement: parse every candidate'supdated_atto an absolute instant before comparing, accepting both layouts above. The space-separated form must be parsed explicitly, treating its trailing offset as authoritative; it must not be handed toDate.parse/new Date(...)and assumed, because that string is not an ISO-8601 date-time and browsers do not agree on how or whether to parse it, with some treating it as local time. AC-66 governs.
- The Office task DTO feeds two of the three triggers, the initial load (AC-59) and every
refetch (AC-49). It passes the SQLite
- A candidate whose
updated_atcannot be parsed is discarded, and is never treated as newest. If parsing fails, that candidate is dropped for that field: neither recorded as canonical nor applied to the displayed value. It must not fall back to the current time, and must not be accepted on the grounds that it arrived most recently. The backend precedent inparsedTaskUpdatedAtdoes fall back totime.Now(), and copying it here would be actively wrong rather than merely lax: this rule discards anything strictly older than the recorded value, so a "now" fallback always compares as newest, always wins, and silently defeats the entire ordering rule for exactly the malformed inputs it exists to protect against. Dropping the candidate is safe because no canonical value is lost: the field keeps the value it already had, and the next load, refetch or successful write records again. AC-67 governs. - The first recording for a field is a seed and is never discarded. The two rules above are
comparisons, and until a field has a recorded canonical value there is nothing to compare
against, so the first recording is made unconditionally: neither the staleness rule, nor the
updated_atordering rule, nor the parse rule can reject it (AC-72). This matters because section F's rollback target is the recorded canonical value (AC-64), so a field that reached an editable state with no canonical value at all would leave a failed commit with nothing to restore. AC-59 makes the initial page load the seed, and it necessarily precedes any edit, so in practice the seed is always in place before a commit can be issued. - An unknown instant loses to a known one. If the seed's
updated_atcould not be parsed, the field has a canonical value but no comparable instant. Any later candidate whoseupdated_atdoes parse supersedes it (AC-73), rather than being discarded for failing a comparison against something uncomparable. Without this the field would be pinned to the seed for the lifetime of the page. This rule closes two races the write-generation rule above does not reach: two refetches resolving out of order (AC-60), and a refetch issued after a commit that nonetheless read a pre-write snapshot and resolves after the write's own response (AC-62). Which rule applies to which trigger, stated rather than left to be inferred. A refetch response must survive both the staleness rule and this ordering rule to be recorded. A write's own response is subject to this ordering rule only; the staleness rule above is written against a refetch response and must not be extended to write responses. Extending it would be actively wrong rather than merely redundant: when two title commits overlap, the second is by definition committed after the first was issued, so a staleness test would discard the first commit's own response, and if the server happened to commit that first write last, the client would record the losing value and disagree with the row permanently.
- This applies to all three record triggers, a write's own response (AC-58) included, not only
to refetches. That is load-bearing rather than tidiness: when two title commits overlap and the
server commits them in the opposite order to the one the client issued them in, the two write
responses carry the row's two successive
- The in-flight guard (applies to the displayed value only). A canonical value is applied to a
field's displayed value only when that field has no unresolved write of this client's own and
no open editor for it. The guard is per field and covers three windows that would otherwise
each lose a user's work:
- an open editor (AC-28): the draft is never touched;
- an in-flight optimistic title, where the editor has already closed at commit time (AC-3), so an editor-open test alone does not protect it (AC-38);
- a refetch triggered by an unrelated concurrent mutation on the same task, for example another property picker firing while a title PATCH is still in flight; that refetch can read a snapshot taken before this client's write and would otherwise revert it (AC-39). A field with no unresolved write and no open editor is unguarded, and a canonical value lands on it immediately, so an ordinary remote edit still appears promptly.
- Deferred apply on release. A field is unguarded when both conditions hold at once: it has
no open editor and no unresolved write of this client's own. At the moment a field becomes
unguarded, whether because its editor closed by any route (Save, Cancel, Escape, discard-confirm)
or because its last in-flight write resolved, the client applies the field's most recently
recorded canonical value to the displayed value if the two differ. If several canonical values
were recorded while the field was guarded, only the most recent one is applied; the earlier ones
are superseded, not queued. On the successful-save path this runs after the editor closes and
AC-17 renders the saved value, and it can only ever move the display forward onto something newer,
never back onto the pre-save text. Three rules together are what make that true, and all three are
load-bearing: the save's own success has already recorded the persisted value as this field's
canonical value (AC-58), so the canonical value is not stale to begin with; a response whose
request predates the save is discarded as stale (AC-52); and any surviving response carrying an
older
updated_atis discarded by the ordering rule (AC-60). Earlier drafts of this document rested the same claim on AC-52 alone, which was wrong: a canonical value recorded legitimately before the save was issued is not stale under AC-52 and would have been applied over the saved text. This is the half of the convention the guard would otherwise drop. Without it, a canonical value that arrived while the field was guarded would be recorded and never shown: a remote edit landing during an open editor that the user then cancels would leave stale text on the page forever, because no further event is coming. AC-50 governs, and it is what keeps AC-27 true in the guarded case.apps/web/AGENTS.mdnames this exact shape as the established convention for this class of race: "guard responses with per-scope revision and request/workspace generation; discard or refresh stale responses and cover deferred responses". - Guard lifetime: the guard is DERIVED, never independently set. A field is guarded exactly when
at least one of two underlying facts holds: an editor is open for it, or a write of this
client's own to it is unresolved. The guard is computed from those two facts; it is not a
third piece of state that one code path sets and another must clear. That shape is
mandatory rather than stylistic, because a separately-mutated flag is precisely what can be left
set by a path that never runs its clear, and a permanently-set guard is invisible: it produces no
error, it silently freezes the field's Office task store entry for that task, and AC-61 makes the
WebSocket handler honour it, so the task's cards on the list and board stop updating for the rest
of the session with nothing to indicate why. The lifetime rules follow from the derivation and are
stated so Build does not have to derive them:
- Creation. No explicit creation step exists. A (task, field) pair is guarded from the moment one of the two facts becomes true. The guard is keyed by task and field, not by component, and there is at most one open-editor contribution per field because the Office simple pane is the only surface this feature gives an editor to. A second view of the same task does not add a second contribution.
- Editor closing, including unmount and route change. When the detail page unmounts, or the route changes away from it, its open-editor contribution for both fields ends unconditionally, exactly as if the editor had been closed (AC-68). An unmount is not a special case that skips cleanup; it is the ordinary close path.
- A write resolving. A write's contribution ends when it resolves, whether it succeeded or failed (AC-70). A failed write is a resolved write. This is called out because the failure path is the one a builder is most likely to leave without a release, and because it also records no canonical value, so nothing later would correct a guard left set by it.
- A write that resolves after the page is gone. It still records its canonical value (AC-58 is unconditional and does not depend on a mounted page) and it still ends its own contribution. The deferred apply then has no displayed value to write to, so it applies to the Office task store entry only (AC-69). The task's card therefore ends on the persisted value even though the user navigated away mid-write.
- Garbage collection. No guard entry may outlive both of its underlying facts. Once a field has no open editor and no unresolved write it is unguarded and holds no state, and an implementation that keeps a per-task map must not leave a set entry behind for a task the user has left (AC-69).
- Why the guard is needed at all here.
refetchTaskinapps/web/app/office/tasks/[id]/page.tsxcallssetTask(mapOfficeTaskToTask(res.task)), replacing the whole task object rather than merging fields, andpatchTaskInStorewrites the store entry the same way. Applying a response wholesale is therefore the default behaviour that has to be constrained. - The Office task store follows the same rule, with per-field timing. A title edited on the
detail page must update the task's card in list and board views. The title store patch is
applied at commit-issue time, in lockstep with the optimistic render (AC-3), which is what
makes AC-39's protection meaningful: the store already carries the unconfirmed value while the
request is in flight. The description store patch is applied only on success, because
section C makes description Save non-optimistic and an optimistic card preview would contradict
it (AC-11, AC-56). Both require
titleanddescriptionto be carried by the store patch mapping inapps/web/hooks/use-optimistic-task-mutation.ts, which currently drops both. - There are TWO writers of these fields into the Office task store, and both consult the guard.
The guard is a property of the task and the field, not of the detail-page component. Naming only
the first writer is what previously made AC-39 and AC-56 unsatisfiable:
patchTaskInStore, reached from the optimistic-mutation hook and from the page's refetch path.apps/web/lib/ws/handlers/office.ts. Itsoffice.task.updatedhandler callsupdateTaskStatus(taskId, normalizeIssueFields(p)).normalizeIssueFieldsmapsp.titleandp.descriptionoff the event payload, andupdateTaskStatusmerges them intostate.office.tasks.itemswith a rawstore.setState, synchronously on every event, bypassingpatchTaskInStoreand everything this section constrains. Fortitleanddescriptionboth writers are subject to the per-field guard (AC-61). The practical consequence, which Build must plan for rather than discover: the guard state has to be reachable from a module-level store subscriber, so component-local state alone cannot satisfy AC-61. Where that state lives is Build's choice; that both writers read the same guard is not.
- Why the WS handler is routed through the guard rather than stripped. Two alternatives were
considered and rejected, and this is recorded because the choice was left open rather than
directed. Stripping
titleanddescriptionfromnormalizeIssueFields, so the guarded refetch path became the sole store writer, is the smaller diff and would also satisfy AC-39 and AC-56. It was rejected because that handler is shared by the Office list and board, where no editor is ever open and no write is ever in flight: stripping the two fields would make every remote title change on those surfaces wait for a refetch instead of landing synchronously, which is a regression for users on surfaces this feature otherwise does not touch. Accepting the handler as an unguarded writer, and weakening AC-39 and AC-56 to match, was rejected because AC-39 exists precisely for the routine case of an unrelated picker firing mid-write. Routing keeps the synchronous fast path for the common unguarded case, keeps both ACs intact, and puts the rule in one place, so a third writer added later inherits it instead of silently defeating it the way this one did. - The store's unguarded path is deliberately not ordered, and that is stated rather than left
silent. While a field is unguarded the WS handler writes the payload value into the store
directly, and a refetch response may write a different value moments later. Both carry server
state, WS delivery on a single connection is ordered, and any disagreement is corrected by the
next event or refetch, so the window is bounded and self-correcting. No ordering guarantee is
claimed for the store entry while the field is unguarded and no test should assert one. The
updated_atordering rule governs canonical recording, which is what the displayed value is derived from; it is not extended to the store's unguarded fast path. - Store rollback is field-scoped. A failed title commit rolls back the store entry's
titleunder the same sequence rule and to the same target as the displayed title (section F, "Rollback ordering": the currently recorded canonical value, AC-64), and rolls back nothing else. Without this the task's card keeps showing a rejected, unpersisted title permanently, because a failed write is never persisted and notask.updatedis ever published to correct it. Note thatuseOptimisticTaskMutationtoday restores the wholestoreSnapshoton failure, which would revert unrelated fields another writer changed in the meantime; it must not be reused unchanged for this. AC-55 governs. - A manual title edit clears any pending agent-generated title for the task:
Service.UpdateTaskdeletes theagent_title_pendingandagent_title_owner_session_idmetadata keys whenever a title is supplied, and the row is then written unconditionally. A human title always wins over an agent title that has not landed yet. A description-only edit leaves the pending marker intact, and the backend's conditional title write then applies; see section F.
F. Ordering, concurrency, and retries
- Ordering. The two fields are independent single-column writes to one row; this feature
introduces no list, no sort, and no tiebreak. When several edits are committed in sequence, the
effective order is commit order, and the persisted
tasks.updated_atis stamped inside the write transaction (repository/sqlite/task.go,updateTaskTx), so its ordering matches true serialization order. - Concurrency, general case.
Service.UpdateTaskis a read-modify-write: it loads the row, applies the patch, and issues a full-rowUPDATE. For every column excepttitleand the title-related metadata keys there is no version orupdated_atprecondition, so two writers resolve last-commit-wins, and a write can carry a stale value for a field it did not intend to change if a different writer committed inside the window between its read and its write. This is pre-existing behaviour of the shared endpoint, it applies equally to every current caller, and this feature does not change it. Adding general optimistic concurrency control is named out of scope below. - Concurrency, the title exception (do not assume last-commit-wins here).
updateTaskTxis not a single statement. When the caller's in-memory snapshot still carriesagent_title_pending, it issues a conditional update whose title assignment istitle = CASE WHEN <live pending marker> THEN ? ELSE title END, together with a JSON merge of the title metadata. That is a genuine compare-and-swap against live database state, it exists so an agent-generated title cannot overwrite a human one that landed first, and it is covered by dedicated tests inapps/backend/internal/task/repository/sqlite/task_title_cas_test.go(TestSetTaskTitleIfPendingRequiresTrueMarker,TestSetTaskTitleIfPendingRejectsNonOwner,TestUpdateTaskPreservesWinningTitleAgainstStaleUpdate,TestClaimTaskTitleSessionIsSingleOwner).Service.UpdateTaskacknowledges this in a comment: the post-write snapshot may be stale precisely because a conditional title patch was applied. This feature exercises that path directly: a description-only save on a task whose agent title is still pending goes through the conditional statement, and AC-48 requires it to be tested rather than assumed. Do not "simplify" this branch and do not treat that window as an unguarded race already excluded from scope. - Rollback ordering (overlapping title commits). Title commits are sequenced. Each commit takes a monotonically increasing sequence number when it is issued. A failed commit restores the title only if it is the last word standing: only if there exists no later-sequenced commit that has already resolved successfully and no later-sequenced commit that is still unresolved. Otherwise the restore is skipped and the displayed title is left alone. The error is surfaced as a toast either way, and the same condition governs the store rollback (section E).
- What a rollback restores: the field's currently recorded canonical value, read at the moment the
restore runs. It is not a per-commit snapshot of whatever happened to be displayed when that
commit was issued. This definition is corrected from an earlier draft and the reasoning is
recorded here so it is not rediscovered the hard way. A value captured at issue time can be the
optimistic value of an earlier commit that later failed, a title the server never persisted;
restoring it puts the page into precisely the state AC-44's rationale says must never occur. A
canonical value cannot carry that defect by construction: section E records one only from the
initial load, a surviving refetch, or a successful write, so every canonical value is one the
server confirmed it holds. AC-64 governs. Two consequences, both load-bearing:
- Restoring is idempotent. Two failed commits that both restore land on the same value, so the order in which they resolve cannot change the end state.
- The restore and section E's deferred apply agree by construction, not by sequencing. Both write the same canonical value, so when a guard release and a rollback coincide it does not matter which runs first. No ordering between them is specified, and none is needed. The exclusions are load-bearing and none is redundant. The partition is over the state of later-sequenced commits, and all four of its cases are named:
- A later commit that already succeeded (AC-44). Restoring here would silently revert a newer successful commit, leaving a title that is neither what the user typed nor what is stored.
- A later commit still in flight (AC-53). The displayed title currently shows that newer commit's optimistic value. Restoring the older snapshot would overwrite it, and a commit succeeding re-renders nothing on its own (AC-3 renders at issue time only), so the newer commit's success would not correct the display. It would be corrected only later and only indirectly, when a canonical value arrives and the deferred apply runs, which is at least an event round-trip away and shows the user a title they did not type in the meantime. The narrower rule that named only the already-resolved case therefore mandated a mismatch it was written to prevent.
- A later commit that has already resolved with FAILURE is not an exclusion, and does not block this commit's restore (AC-65). This is the fourth case and it is named explicitly rather than left to be inferred from "neither succeeded nor still unresolved", because leaving it implicit is what previously let a chained double failure land on a never-persisted title: under the old snapshot-based restore target, the later commit's snapshot was the earlier commit's optimistic value, so whichever of the two resolved last restored a title that had never been stored, and the end state depended on network ordering alone. It is safe now because of the corrected restore target above: both commits restore to the same canonical value, so the second restore is a no-op rather than a revert, and the end state is the same whichever order they resolve in.
- No later-sequenced commit at all is the ordinary single-commit case and restores (AC-9).
- Convergence after overlapping commits (both succeed). Sequence numbers order the client's
rollbacks; they do not reorder the network. If commit 1 sends
Band commit 2 sendsCand the requests reach the server out of order, the row keeps whichever write committed last, which may beB, while the display showsC. This feature does not add ordering guarantees to the wire and does not re-send to force a winner. It converges instead, and by a shorter path than an event round-trip: each write's own response records the row's value for that field as canonical, ordered byupdated_at(section E), so by the time the later-resolving commit's response has landed, the canonical value is already whichever write the row actually kept. Once the last commit resolves the title is unguarded and the deferred-apply rule lands that value on the display. The refetches driven by eachtask.updatedare a second, redundant path to the same end state rather than the mechanism convergence depends on. The end state is therefore the row's value, reached without a page reload, and the window in which the display disagrees with the row is bounded by the in-flight commits alone. AC-54 observes this. Users who care about which of two rapid renames wins are asking for general optimistic concurrency control, which is named out of scope below. - Late-arriving refresh. A WS-driven refetch that lands while an editor is open must not overwrite the user's draft, and must not overwrite an in-flight optimistic value either; the per-field guard in section E states the full rule. The field's canonical value behind an open editor may change, and is recorded when it does; what the guard suppresses is only its application to the displayed value, and that application is deferred to the moment the editor closes rather than dropped (section E, "Deferred apply on release"). Meanwhile the editor's baseline is frozen at open time (section C for the description, section B for the title), so Save, Cancel, and Escape behaviour never changes underneath the user as a result of somebody else's edit. Those two statements are consistent because they are about different values: the canonical value moves, the displayed value and the baseline do not.
- Idempotency. A commit whose trimmed value equals the baseline or the current title issues no
request. Committing the same value twice is therefore a single write followed by a no-op, and
re-sending an identical
PATCHconverges on the same value:titleanddescriptionare assigned, not accumulated, so N identical requests leave the same row as one. It is idempotent in that sense only. Each request is still a real write that re-stampsupdated_atinside the transaction and republishestask.updated, so duplicates are observable as extra events and extra refetches even though the field value never moves. This feature adds no idempotency key and no duplicate-request suppression, and needs neither: it issues no automatic retries, and a commit equal to the baseline issues no request at all, so this client never sends a duplicate of its own. - Retries. No automatic retry on failure. A failed title commit rolls back subject to the rollback-ordering rule above and toasts; a failed description save keeps the editor open with the draft and toasts. In both cases the next attempt is a fresh user action.
G. Errors
- Network failure,
5xx, or a task that no longer exists (404) means the edit does not apply, the prior value is restored (title) or the editor stays open with the draft (description), and the message surfaces as a toast. 400from title validation is unreachable through the UI, because the input clamps at the same 60-character limit the server enforces. If it is returned anyway, it is surfaced as a toast like any other failure; no special-cased inline error is required.- The failure path never leaves an invisible unsaved draft. Either the editor is open and the draft is visible, or the editor is closed and the visible text is the stored text.
H. Interaction details a builder would otherwise have to invent
- Keyboard parity. Both affordances are reachable and operable without a pointer. The title element and the description element (or its empty-state placeholder) are focusable, and Enter on the focused element opens the corresponding editor. Pointer activation is double-click for the title and single click for the description; the asymmetry is deliberate: a single click on a paragraph of prose is how every comparable product opens a description, while a single click on a page heading is not, and the title's double-click matches the existing Kanban rename.
- Click versus text selection. A pointer interaction on the description that ends with a non-empty text selection does not open the editor. Selecting and copying description text must remain possible.
- The two editors are independent in state, but not symmetric about focus. Neither editor ever
commits the other, and a dirty description draft never blocks a title commit (AC-35). They are
not, however, both freely open at once, and the asymmetry follows from blur:
- The description editor survives blur (AC-21), so it stays open with its draft while the user opens, edits, and commits the title editor. That order is safe and AC-35 covers it.
- The title editor cancels on blur (AC-5). Opening the description editor necessarily moves focus off the title input, whether by pointer or by keyboard, so the title editor closes and its draft is discarded. AC-57 states this explicitly rather than leaving it to be discovered. This is deliberate, not an oversight: the title editor is transient by design, matching the existing Kanban rename, and section C's rationale explains why a lost title draft is cheap (one line, at most 60 characters) while a lost description draft is not. A user who wants to keep a title draft commits it with Enter first. If a future card wants both editors genuinely co-open, that is a change to AC-5, not to this bullet.
- Overlapping title commits. A second title commit issued while the first is still in flight is allowed. Sequencing and the rollback rule are specified in section F under "Rollback ordering"; that rule, not a per-commit snapshot alone, is what guarantees a failure never reverts a newer successful commit.
- Cancel confirmation shape. The AC-19 confirmation is a modal with two choices: discard the draft and close the editor, or return to the editor with the draft intact. Dismissing the modal by any other means (Escape, backdrop, close button) returns to the editor with the draft intact. The draft is only ever destroyed by the explicit discard choice.
- Save and Cancel placement. Save and Cancel render below the textarea, Save first and styled as
the primary action, Cancel secondary. This is a fresh product decision for this feature, not a
port:
project-header.tsxhas a single Save button rendered above its textarea and no Cancel control at all, so it is a precedent for gating Save on dirty state and for nothing else. - Invalid-title feedback. AC-7's invalid state is both programmatic and visible:
aria-invalidon the input plus a short visible message adjacent to it. It clears as soon as the draft becomes non-empty. - Stable test hooks. The implementation exposes these
data-testidvalues, and the E2E spec addresses the feature through them rather than through copy:office-task-title,office-task-title-input,office-task-title-error,office-task-description,office-task-description-empty,office-task-description-textarea,office-task-description-save,office-task-description-cancel,office-task-description-discard-confirm,office-task-description-running-note.
Acceptance criteria
Written in EARS form. Each is observable from the Office task detail page or from the persisted row.
Title
- AC-1: WHEN the user double-clicks the task title on the Office simple pane, THE SYSTEM SHALL replace it with a focused single-line input seeded with the current title and its content selected.
- AC-2: WHEN the title element has keyboard focus AND the user presses Enter, THE SYSTEM SHALL enter the same edit state as AC-1.
- AC-3: WHILE the title editor is open AND the trimmed draft is non-empty AND differs from the
stored title, WHEN the user presses Enter, THE SYSTEM SHALL render the trimmed draft
immediately, send
PATCH /api/v1/tasks/:idwith a body containing onlytitleset to that same trimmed value, and close the editor. - AC-4: WHILE the title editor is open, WHEN the user presses Escape, THE SYSTEM SHALL discard the draft, close the editor, restore the stored title, and send no request.
- AC-5: WHILE the title editor is open, WHEN the input loses focus, THE SYSTEM SHALL behave exactly as in AC-4.
- AC-6: WHILE the title editor is open, WHEN the user enters or pastes text that would take the field beyond 60 code points, THE SYSTEM SHALL retain only the first 60 and SHALL leave the caret at the position the user was typing or pasting at, not at the end of the field.
- AC-7: WHILE the title editor is open AND the trimmed draft is empty, WHEN the user presses Enter, THE SYSTEM SHALL keep the editor open, mark the field invalid to assistive technology, and send no request.
- AC-8: WHILE the title editor is open AND the trimmed draft equals the stored title, WHEN the user presses Enter, THE SYSTEM SHALL close the editor and send no request.
- AC-9: IF the title
PATCHfails AND no later-sequenced title commit has already resolved successfully AND no later-sequenced title commit is still unresolved, THEN THE SYSTEM SHALL restore the title's currently recorded canonical value (AC-64) and surface the error as a toast. (Both exclusions are required: this is the "last word standing" condition from section F, and AC-44 and AC-53 are the two cases it excludes. An earlier version of this criterion carried only the first exclusion, which made it mandate a restore in exactly the situation AC-53 forbids. A second earlier version restored a per-commit snapshot of the displayed title, which could be a never-persisted value; AC-64 replaced that target and AC-65 observes the case that exposed it.) - AC-10: WHILE an IME composition is active, WHEN the user presses Enter, THE SYSTEM SHALL NOT commit the title and SHALL leave the editor open.
- AC-11: WHEN a title commit is issued, THE SYSTEM SHALL patch the task's entry in the Office task store with the trimmed draft at that moment, before the request resolves, so list and board surfaces show the new title without waiting for a WebSocket message. (Issue time, not success time: AC-39's protection of that store entry during the in-flight window is only meaningful if the entry already carries the unconfirmed value.)
- AC-44: IF a title
PATCHfails AND a later-sequenced title commit has already resolved successfully, THEN THE SYSTEM SHALL leave the displayed title unchanged, SHALL NOT perform a restore, and SHALL still surface the error as a toast. - AC-51: WHILE the title editor is open, THE SYSTEM SHALL use the title displayed at editor-open time as the frozen baseline for AC-4's restore target and AC-8's equality test, AND SHALL NOT change that baseline when a newer canonical title is recorded for the task.
- AC-53: IF a title
PATCHfails AND a later-sequenced title commit is still unresolved, THEN THE SYSTEM SHALL leave the displayed title and the Office task store entry unchanged, SHALL NOT perform a restore, and SHALL still surface the error as a toast. - AC-54: WHEN two overlapping title commits both succeed, THE SYSTEM SHALL converge the displayed title on the task's stored value once no title write is in flight and no title editor is open, without a page reload, regardless of the order in which the two requests reached the server.
- AC-55: IF a title commit fails AND the rollback condition in section F is met, THEN THE SYSTEM
SHALL set the Office task store entry's
titleto the title's currently recorded canonical value and SHALL leave every other field of that store entry at its current value. - AC-64: WHEN a failed title commit performs a restore, THE SYSTEM SHALL restore the title's currently recorded canonical value read at the moment the restore runs, and SHALL NOT restore any value captured from the display at commit-issue time.
- AC-65: WHEN two overlapping title commits both fail, THE SYSTEM SHALL leave the displayed title and the Office task store entry at the title's currently recorded canonical value, and SHALL reach the same end state regardless of which of the two commits resolves first.
Description
- AC-12: WHEN the user clicks the task description, THE SYSTEM SHALL replace it with a focused textarea seeded with the current description.
- AC-13: WHERE the task has no description, THE SYSTEM SHALL render a persistent placeholder affordance in the description's position, and WHEN the user clicks it, THE SYSTEM SHALL enter the same edit state as AC-12 with an empty draft.
- AC-14: WHILE the description editor is open AND the trimmed draft differs from the frozen baseline, THE SYSTEM SHALL display an enabled Save control and a Cancel control.
- AC-15: WHILE the description editor is open AND the trimmed draft equals the frozen baseline, THE SYSTEM SHALL display Save in a disabled state.
- AC-16: WHEN the user activates Save, THE SYSTEM SHALL send
PATCH /api/v1/tasks/:idwith a body containing onlydescriptionset to the trimmed draft, SHALL keep the editor open in a saving state until the request resolves, and SHALL disable Save for the duration. - AC-17: WHEN a description save succeeds AND the current trimmed draft still equals the submitted value, THE SYSTEM SHALL close the editor and render the saved value.
- AC-18: IF a description save fails, THEN THE SYSTEM SHALL keep the editor open with the draft unchanged and surface the error as a toast.
- AC-19: WHILE the description editor is open AND the trimmed draft differs from the frozen baseline, WHEN the user activates Cancel, THE SYSTEM SHALL require an explicit confirmation before discarding the draft.
- AC-20: WHILE the description editor is open AND the trimmed draft equals the frozen baseline, WHEN the user activates Cancel, THE SYSTEM SHALL close the editor immediately without a confirmation.
- AC-21: WHILE the description editor is open, WHEN the textarea loses focus, THE SYSTEM SHALL keep the editor open, keep the draft, and send no request.
- AC-22: WHILE the description editor is open AND the trimmed draft differs from the frozen baseline, WHEN the user presses Escape, THE SYSTEM SHALL take no action.
- AC-23: WHILE the description editor is open AND the trimmed draft equals the frozen baseline, WHEN the user presses Escape, THE SYSTEM SHALL close the editor.
- AC-24: WHEN a description save commits an empty trimmed draft on a task that previously had a description, THE SYSTEM SHALL clear the stored description and SHALL then render the placeholder affordance from AC-13.
- AC-25: WHILE at least one session on the task is in state
RUNNING, WHEN the description editor is open, THE SYSTEM SHALL display a non-blocking note stating the change applies at the agent's next wakeup, and SHALL NOT disable Save. - AC-40: WHILE the description editor is open, WHEN an
office.task.updatedrefetch changes the task's stored description, THE SYSTEM SHALL leave the editor's frozen baseline unchanged, so that the enabled state of Save and the behaviour of Cancel and Escape do not change as a result. - AC-41: WHILE a description save is in flight, THE SYSTEM SHALL keep the textarea editable, SHALL disable both Save and Cancel, and SHALL take no action on Escape.
- AC-42: WHEN a description save succeeds AND the user changed the draft while the save was in flight, THE SYSTEM SHALL keep the editor open with the newer draft intact, SHALL set the baseline to the value that was saved, and SHALL re-enable Save while the newer trimmed draft differs from it.
- AC-43: WHERE the stored description is empty, WHILE the description editor is open AND the draft contains only whitespace, THE SYSTEM SHALL treat the draft as equal to the baseline, SHALL keep Save disabled, and SHALL send no request.
- AC-45: WHEN the user saves a description of any length, THE SYSTEM SHALL send and persist it unmodified apart from whitespace trimming, applying no client-side or server-side length limit.
- AC-46: WHEN a description containing Markdown syntax is rendered, THE SYSTEM SHALL display it as literal plain text with whitespace preserved, and SHALL NOT render it as formatted Markdown.
- AC-56: WHEN a description save is issued, THE SYSTEM SHALL NOT patch the Office task store entry,
and WHEN that save succeeds, THE SYSTEM SHALL patch the store entry's
descriptionat that moment, so card previews never show a description that has not been persisted.
Shared
- AC-26: WHEN either field is committed AND no other writer commits to the same task inside the
request window, THE SYSTEM SHALL send only the edited field in the request body and SHALL leave
every other task property unchanged in the response. This feature asserts no outcome when a
concurrent writer does commit inside that window:
Service.UpdateTaskis last-commit-wins for every column except the existing title compare-and-swap (section F), this feature neither adds nor removes that behaviour, and no test should assert a required winner. Section F describes what the backend does today; it does not impose a new requirement here. - AC-27: WHEN another client edits the same task, THE SYSTEM SHALL update the displayed title and
description without a page reload: immediately on receipt of
office.task.updatedWHERE the field is unguarded, and otherwise at the moment that field becomes unguarded, per the deferred apply in AC-50. This criterion promises convergence without a reload; it does not promise application during a guarded window. AC-28 and AC-38 govern the guarded window and take precedence there. (An earlier version said only "on receipt", which contradicted the guard and would have failed a correct implementation.) - AC-28: WHILE either editor is open, WHEN an
office.task.updatedrefetch resolves for this task, THE SYSTEM SHALL leave the open draft unmodified. - AC-38: WHILE a title commit is in flight AND the title editor has already closed, WHEN an
office.task.updatedrefetch for this task resolves, THE SYSTEM SHALL leave the optimistically rendered title unchanged. - AC-39: WHILE a title commit is in flight, WHEN a refetch triggered by an unrelated mutation on the same task resolves and carries a title predating that commit, THE SYSTEM SHALL leave both the displayed title and the Office task store entry unchanged.
- AC-49: WHEN a non-stale refetch for this task resolves, THE SYSTEM SHALL record its value for each field as that field's canonical value, whether or not an editor is open for that field and whether or not a write to that field is in flight.
- AC-50: WHEN a field becomes unguarded, because its editor closed by any route or because its last in-flight write resolved, THE SYSTEM SHALL apply that field's currently recorded canonical value to the displayed value if the two differ, so that a remote edit received while the field was guarded becomes visible without a page reload.
- AC-52: IF a refetch response's request was issued before this client's most recent committed write to a field, THEN THE SYSTEM SHALL discard that response for that field, neither recording it as canonical nor applying it to the displayed value.
- AC-58: WHEN a write to a field resolves successfully, THE SYSTEM SHALL record the value that request persisted for that field, read from the response body, as that field's canonical value, AND SHALL do so before releasing that field's guard.
- AC-59: WHEN the task detail page completes its initial load, THE SYSTEM SHALL record the loaded title and description as those fields' canonical values.
- AC-60: IF a candidate canonical recording carries an
updated_atstrictly older than theupdated_atcarried by the field's currently recorded canonical value, THEN THE SYSTEM SHALL discard the candidate for that field, neither recording nor applying it, so that two refetches resolving out of order leave the newer value in place. - AC-61: WHILE a field is guarded, WHEN an
office.task.updatedevent is received, THE SYSTEM SHALL leave that field's Office task store entry unchanged, INCLUDING when the store write originates in the WebSocket handler rather than in the page's refetch path. - AC-62: IF a refetch was issued after a commit to a field AND its response carries an
updated_atolder than the one recorded by that commit's own successful response, THEN THE SYSTEM SHALL discard that response for that field. - AC-63: WHEN two candidate recordings for a field carry exactly equal
updated_atvalues, THE SYSTEM SHALL accept the later-arriving one, ordered by this client's request-issue sequence for the task. - AC-66: WHEN comparing two candidate recordings'
updated_atvalues, THE SYSTEM SHALL parse each to an absolute instant before comparing, SHALL accept both the space-separated offset-bearing layout emitted by the Office task DTO and the RFC3339Nano layout emitted by a write response, and SHALL NOT compare the values as raw strings. - AC-67: IF a candidate recording's
updated_atcannot be parsed to an absolute instant, THEN THE SYSTEM SHALL discard that candidate for that field, neither recording it as canonical nor applying it to the displayed value, and SHALL NOT treat it as newer than the recorded value. - AC-68: WHEN the task detail page unmounts or the route changes away from it, THE SYSTEM SHALL end the open-editor contribution to the guard for both fields, so that a field with no unresolved write becomes unguarded.
- AC-69: WHEN a write resolves after the task detail page has unmounted, THE SYSTEM SHALL record its canonical value per AC-58, SHALL end that write's contribution to the guard, and SHALL leave no guard entry in place for a field that has no open editor and no unresolved write.
- AC-70: WHEN a write to a field fails, THE SYSTEM SHALL end that write's contribution to the field's guard exactly as it does for a write that succeeds.
- AC-71: WHEN a canonical value is recorded for
description, THE SYSTEM SHALL normalise an absent value (undefined,null, or an omitted field) to the empty string before recording it, so that a guard release on a task with no description applies nothing. - AC-72: WHEN a field has no recorded canonical value, THE SYSTEM SHALL record the next candidate
for that field unconditionally, applying neither the staleness rule, nor the
updated_atordering rule, nor the parse rule to it. - AC-73: IF a field's recorded canonical value carries an
updated_atthat could not be parsed, THEN THE SYSTEM SHALL accept the next candidate whoseupdated_atdoes parse, superseding the recorded value. - AC-29: WHEN a title is committed on a task carrying a pending agent-generated title, THE SYSTEM SHALL persist the user's title and SHALL clear the task's pending-agent-title metadata.
- AC-48: WHEN a description-only save is committed on a task carrying a pending agent-generated title, THE SYSTEM SHALL persist the new description, SHALL leave the pending-agent-title metadata intact, and SHALL leave the stored title unchanged.
- AC-30: THE SYSTEM SHALL offer the title and description editors regardless of the task's status and regardless of any session state, introducing no status-based or lifecycle-based gate.
- AC-47: THE SYSTEM SHALL NOT introduce an archived-task gate on either editor, and SHALL NOT port
the
isArchivedcondition fromapps/web/components/task/task-top-bar-title.tsx. - AC-31: THE SYSTEM SHALL NOT queue an agent run, wake an agent, or interrupt a running session as a result of a title or description edit.
Interaction details
- AC-33: WHEN the title element or the description element has keyboard focus AND the user presses Enter, THE SYSTEM SHALL open the corresponding editor.
- AC-34: WHEN a pointer interaction on the description ends with a non-empty text selection, THE SYSTEM SHALL NOT open the description editor.
- AC-35: WHILE the description editor is open with a dirty draft, WHEN the user opens and commits the title editor, THE SYSTEM SHALL commit the title and SHALL leave the description editor open with its draft intact.
- AC-36: WHILE the AC-19 confirmation is displayed, WHEN the user dismisses it by any means other than the explicit discard choice, THE SYSTEM SHALL return to the description editor with the draft intact.
- AC-37: WHILE the title editor is open AND the trimmed draft is empty, THE SYSTEM SHALL mark the
input
aria-invalidand display a visible message, and SHALL clear both as soon as the trimmed draft is non-empty. - AC-57: WHILE the title editor is open, WHEN the user opens the description editor, THE SYSTEM SHALL close the title editor, discard its draft, restore its frozen baseline title, and send no request.
Documentation reconciliation
- AC-32:
docs/specs/office/tasks.mdSHALL be amended so that its editable-property list at### D. Inline editable propertiesnames Title and Description, and itsPATCH /tasks/:identry under## API surfacestates that title and description are edited throughPATCH /api/v1/tasks/:idand are not fields of the Office endpoint. No other behaviour in that document changes.
Surfaces touched
apps/web/components/task/simple/OfficeSimplePane.tsx: the<h1>at line 512 and the description block at lines 513-517. The component is 574 lines against a 600-line lint ceiling, so the editors are expected to be extracted into sibling components rather than added inline.- AC-25's data source is already in scope:
OfficeSimplePanePropsdeclaressessions: TaskSession[], andTaskSession["state"]already includes"RUNNING". No new fetch, prop, or store field is required for the running-session note. apps/web/hooks/use-optimistic-task-mutation.ts:toOfficeTaskPatchmust carrytitleanddescription, and the section E rules apply to the store patch it writes. This hook does not fit both fields as it stands, and that is expected, not a defect to work around. Its only shape is apply-patch-then-await: it callsctx.applyPatchandpatchTaskInStorebeforeawait apiCall(), and on failure restores the wholestoreSnapshot. That matches the title (optimistic at issue time, AC-11) and does not match the description (store patch on success only, AC-56), and its whole-object rollback is wrong for both (AC-55 requires a field-scoped restore). Build is expected to reshape or extend the hook rather than silently accept pre-await timing for the description; which of the two it is is an implementation decision, but the timings in AC-11 and AC-56 and the field-scoping in AC-55 are not.apps/web/app/office/tasks/[id]/page.tsx:refetchTaskreplaces the whole task object, so the per-field in-flight guard from section E is applied here. It carries no request ordering of any kind today, anduseOfficeRefetch(apps/web/hooks/use-office-refetch.ts) has no debounce, no coalescing and no in-flight cancellation, so concurrent refetches for one task are the normal case rather than an edge case. That is why AC-60 exists.apps/web/lib/ws/handlers/office.ts: the second store writer.normalizeIssueFields(thep.title/p.descriptionmapping) andupdateTaskStatus(the rawstore.setState) are both in scope, because together they write these two fields into the Office task store outsidepatchTaskInStore. AC-61 governs. Prior art for the ordering half of section E, worth reading before implementing it:nextRequestSequence/isLatestRequestinapps/web/hooks/use-office-workspace-data.tsalready give every other Office loader last-request-wins, for exactly this reason.apps/web/lib/api/domains/kanban-api.ts:updateTask(), used as-is. Note its return value is now load-bearing: it resolves to the updatedTask, and AC-58 records that response's value for the written field as canonical. A call site that discards the response cannot satisfy AC-58.docs/specs/office/tasks.md: reconciliation per AC-32.- i18n: new copy in
enpluspt-pt,zh-cn,zh-hk,zh-tw.common:save,common:cancel,task:description,task:taskTitle,task:saving,task:updateFailedandoffice:addADescriptionalready exist and should be reused rather than duplicated. One trap:task.jsoncarries bothdescription(value"description", lowercase, used today as an inlineKeyValueRowlabel) anddescription2(value"Description", capitalized). Pick by the element: a standalone visible label wantstask:description2, an inline or aria-only string wantstask:description. Do not add a third key for the same word.
No backend file changes are required by this spec. AC-48 adds a test over existing backend behaviour; it does not change it.
Test expectations
Every acceptance criterion has a declared verification below. AC ids are enumerated individually rather than as ranges, so coverage can be checked mechanically: every id from AC-1 to AC-73 appears at least once in this section.
- Unit (vitest), alongside
apps/web/components/task/simple/OfficeSimplePane.test.tsx:- Title editor open / commit / cancel / blur / clamp / paste-clamp / empty / unchanged / IME paths: AC-1, AC-2, AC-4, AC-5, AC-6, AC-7, AC-8, AC-10, AC-33, AC-37.
- Title trim on send and on optimistic render: AC-3.
- Title rollback, all four branches of the sequence rule, one test each: the last-word-standing case with no later commit at all (AC-9); a later commit that already succeeded (AC-44); a later commit still unresolved (AC-53); and a later commit that already resolved with failure, which restores (AC-65). AC-53 is the branch whose absence previously made AC-9 mandate the wrong outcome, and the fourth branch is the one whose absence let a chained double failure land on a never-persisted title, so both are asserted explicitly and neither is folded into AC-9's test.
- The rollback target (AC-64): a failed commit restores the title's currently recorded canonical value, not a value captured from the display at commit-issue time. Assert this directly, with a canonical value that differs from what was displayed when the commit was issued, so a snapshot-based implementation fails.
- The chained double failure, asserted in both resolution orders (AC-65). Two overlapping title commits, both failing: assert the displayed title and the Office task store entry both end at the canonical (pre-chain, actually stored) title when the earlier commit resolves first, and again when the later one does. This is the regression test for the defect the four-branch partition and AC-64 were written to close; a test that runs only one resolution order would have passed against the broken spec.
- Office task store patch mapping, and the timing split between the two fields: AC-11 (title patched at commit-issue time, asserted before the request resolves) and AC-56 (description not patched at issue time, patched on success).
- Field-scoped store rollback on a failed title commit: AC-55 asserts the store entry's
titleis restored and that a second field of the same store entry, changed in the meantime, is not. - The title's frozen baseline under a canonical update: AC-51.
- Description open / placeholder / dirty / Save / Cancel / confirm / blur / Escape paths: AC-12, AC-13, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-22, AC-23, AC-24, AC-34, AC-36.
- Frozen baseline under a refetch: AC-40. Draft survives a refetch while open: AC-28.
- In-flight control state and in-flight editing: AC-41, AC-42.
- Whitespace-only draft on an empty description: AC-43.
- No length limit and plain-text rendering: AC-45, AC-46.
- Running-session note: AC-25.
- In-flight guard for the optimistic title, editor closed and unrelated-mutation refetch: AC-38, AC-39.
- The canonical/displayed rules from section E, one test each: recording is never suppressed (AC-49); the deferred apply fires when the guard releases, asserted through the case that motivated it, a remote description edit arriving during an open editor that the user then cancels (AC-50); a response whose request predates the local write is discarded rather than recorded (AC-52).
- The three record triggers, enumerated in section E. The initial load records both fields (AC-59). A successful write records the value its own response persisted, asserted before the guard releases (AC-58). The regression this pair exists to prevent is asserted directly and on both fields: with no refetch in play at all, a successful title commit and a successful description save each leave the displayed value at the saved text after the guard releases, rather than reverting to the pre-write value.
- The
updated_atordering rule, one test per branch: two refetches resolving out of order leave the newer value in place (AC-60); a refetch issued after a commit but carrying an olderupdated_atthan the commit's own response is discarded (AC-62); two candidates with exactly equalupdated_atresolve by request-issue sequence (AC-63). - Cross-format parsing (AC-66). The fixtures must be the two layouts that actually occur,
not two variants of one: a refetch recording in the Office task DTO's raw SQLite form
(
2026-08-20 14:30:05.123456789+00:00, space-separated and offset-suffixed) and a write-response recording inRFC3339Nano(2026-08-20T14:30:05.123456789Z). Assert the ordering comes out right in both directions. A raw string comparison must fail this test, and so must an implementation that parses only theT/Zform, and so must one that hands the space-separated form tonew Date(...)and assumes local time. AC-60's earlier prescribed fixtures (one RFC3339Nano value with a stripped trailing zero and one at second resolution) exercised only the same-format hazard and would have passed against a parser that is wrong for the majority of real comparisons; keep one such pair as an additional case, but it is not the primary one. - The seed rule (AC-72): with no canonical value recorded for a field, the next candidate is
recorded even when it would fail the staleness, ordering or parse rule, so a failed commit
always has a restore target (AC-64). And (AC-73) where the seed's
updated_atdid not parse, a later candidate whoseupdated_atdoes parse supersedes it, so the field is not pinned to the seed for the lifetime of the page. - Unparseable
updated_at(AC-67): a candidate whose timestamp does not parse is discarded for that field, the previously recorded canonical value survives unchanged, and the candidate is not treated as newest. Assert the surviving value explicitly, because a "fall back to now" implementation passes any test that only checks the candidate was not recorded verbatim. - Guard lifetime (AC-68, AC-69, AC-70). Unmounting the detail page while an editor is open
leaves the field unguarded, so a subsequent
office.task.updatedreaches the store entry (AC-68). A write that resolves after unmount still records its canonical value and still ends its contribution, and the store entry ends at the persisted value (AC-69). A failed write ends its contribution exactly as a successful one does, asserted by showing that a later event reaches the store entry after the failure (AC-70). These three are the tests that would catch a guard implemented as an independently-mutated flag rather than derived from its two facts. - Absent-description normalisation (AC-71): on a task with no description, a guard release
applies nothing and fires no store patch, with the load path supplying
description: undefinedand the write path supplying"". Without normalisation the two compare unequal and the test sees a spurious apply. - The WebSocket handler is subject to the guard (AC-61): with a title commit in flight, an
office.task.updatedcarrying the pre-commit title leaves the Office task store entry unchanged. This test must drive the realoffice.tshandler path (normalizeIssueFieldsplusupdateTaskStatus), notpatchTaskInStore, or it asserts nothing. - Convergence after two overlapping title commits that both succeed, with the responses delivered out of order: AC-54.
- Both editors independent, title commits while description draft is dirty: AC-35. And the other order, which is the asymmetric one: opening the description editor while the title editor is open closes the title editor and discards its draft (AC-57).
- No status or lifecycle gate, and no archived gate: AC-30, AC-47.
- Only the edited field is sent, single-writer case: AC-26.
- Backend (Go), alongside
apps/backend/internal/task/service/:- A description-only update on a task carrying a pending agent title leaves the pending metadata
and the stored title intact: AC-48. This exercises the conditional title statement in
updateTaskTxdescribed in section F. - A title update on a task carrying a pending agent title persists the human title and clears the pending metadata: AC-29.
- A title or description update publishes
task.updatedwithoutassignee_agent_profile_idon the payload, soqueueTaskAssignedRuncannot queue a run: AC-31. This is the regression test that makes AC-31 a contract rather than a code-reading, and it fails the moment somebody adds that field topublishTaskEventNow.
- A description-only update on a task carrying a pending agent title leaves the pending metadata
and the stored title intact: AC-48. This exercises the conditional title statement in
- E2E (Playwright), a new spec alongside
apps/web/e2e/tests/office/property-pickers.spec.ts:- Editing the title and the description on a real seeded Office task, asserting both the rendered value and the persisted row: AC-3, AC-16, AC-17, AC-26.
- A second browser context observing the edit converge without a reload: AC-27.
- One route-mocked failure asserting title rollback: AC-9.
- One route-mocked failure asserting the description editor survives with its draft intact: AC-18.
- AC-32 is a documentation change with no runtime behaviour. It is verified by inspection of
docs/specs/office/tasks.mdin the same pull request; Build must confirm both edits landed.
Out of scope
Each exclusion below is a decision, not an omission.
- General optimistic concurrency control on task writes.
Service.UpdateTaskkeeps last-commit-wins for every column except the title compare-and-swap that already exists and is described in section F. Adding a version column or anupdated_atprecondition for the remaining columns would alter every existing caller of the endpoint and belongs in its own card. This feature neither adds nor removes concurrency control in the backend. - Guaranteeing which of two rapid renames wins. Sequence numbers order this client's rollbacks; they do not order the network, and this feature adds no wire ordering, no re-send, and no server-side arbitration. Two overlapping title commits resolve to whichever write the server committed last, and the display converges on it (AC-54). A caller who needs a defined winner is asking for the concurrency control excluded above.
- Description version history, undo, or drafts persisted across page loads. Explicit Save exists precisely because none of these do. A draft lives in component state and is lost on navigation or reload.
- Markdown rendering or a rich-text editor for the description. The field stays plain text with preserved whitespace, exactly as it renders today. AC-46 pins this.
- Extending
PATCH /api/v1/office/tasks/:idto accept title or description. The Office endpoint and itshasAnyField()guard are unchanged, and a{"title": "..."}body sent to it continues to return400 "no mutation fields supplied". - Waking or re-prompting the assigned agent on a content edit. Deliberately not done; see section D. Making a description edit re-brief a running agent is a product decision with its own failure modes and needs its own spec.
- A minimum or maximum length for the description, and any change to the 60-character title limit.
- Editing title or description anywhere else in Office: the task list, board cards, the sidebar, the inbox, and the advanced / dockview mode are untouched.
- The sidebar property pickers. They auto-save optimistically by design
(
docs/specs/office/tasks.md:60) and are E2E-proven. No Save button is added to them. - The blank Created by / Started / Completed rows. Tracked separately.
- An archived-task gate. Not an exclusion of behaviour but of a gate: the Office detail page
renders no archived state and the backend enforces no archived precondition, so no gate is added
and the Kanban
isArchivedcondition is not ported. See section A and AC-47. Introducing archived semantics to this page is separate work.