Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
29 KiB
status, system, requirements, created, owners
| status | system | requirements | created | owners | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| draft | tasks |
|
2026-09-01 |
|
Task plan append-mode write System Design
Purpose and boundaries
This design defines where an append composes the stored document, how the mode travels from the MCP tool call to that point, what the separator is, and where the existing plan size limit is evaluated for a composed write. It does not define the limit's value, revision compaction, or any section-patch mode.
Base state (verified 2026-09-05 against origin/main). The
plan-write-consistency precondition has landed: PlanService holds a
planLockTable (internal/task/service/plan_lock.go) and both CreatePlan and
UpdatePlan acquire it around upsertPlan. Truncation detection moved into the
service (internal/task/service/plan_truncation.go); evaluatePlanWriteGuard no
longer exists, and internal/mcp/handlers/task_plan_guard.go now only renders the
warning text. plan-content-size-limit has also landed, which
Size limit placement exists to account for.
Requirement mapping
| Requirement | Design section |
|---|---|
REQ-TASKS-PLAN-APPEND-001 |
Mode parameter and validation |
REQ-TASKS-PLAN-APPEND-002 |
Separator normalization |
REQ-TASKS-PLAN-APPEND-003 |
Where composition happens, Serialization dependency |
REQ-TASKS-PLAN-APPEND-004 |
Guard placement |
REQ-TASKS-PLAN-APPEND-005 |
Unchanged surfaces |
REQ-TASKS-PLAN-APPEND-006 |
Agent text design |
REQ-TASKS-PLAN-APPEND-007 |
Size limit placement |
Prior art
Our own prior reasoning. Searched the henry vault (local path redacted)
through QMD, collection
wiki, with lexical, semantic and hypothetical-document sub-queries on
append-versus-replace write APIs, the context cost of read-modify-write, and durable agent
memory across context resets. Eight candidates, three read; each position is argued where
applied:
concepts/agents-md-pattern.md— "compaction is not the fix, promotion is": periodic full rewrites of a durable agent-facing file cause measured context collapse. A replace-only API makes every write such a rewrite, so the erosion is structural, not a token-cost inconvenience. Applied in## Out of scope.concepts/optimistic-vs-pessimistic-concurrency.md— Agrawal, Carey and Livny (ACM TODS 12(4), 1987): blocking beats optimistic restart at medium-to-high contention under finite resources. Applied in What the serialization must cover.concepts/agent-replay-non-idempotence.md— re-running an author is not idempotent. Applied inAC-TASKS-PLAN-APPEND-003.6.
What other products shipped. Searched saas-kb via search_fsm_docs,
category: "ai_sdlc", on two queries: agent plan documents with append versus replace edit
tools and their context cost, and file-edit tools that insert rather than rewrite a whole
file. Both returned only Warp and Augment Code tool-permission, hook and quickstart pages
at relevance around 0.01, none describing a plan-document write mode. This leg returned
nothing useful.
What we are doing differently. Neither leg supplied a partial-write contract to copy, so this capability takes its shape from the two positions above: blocking serialization rather than optimistic retry, and a stated non-idempotence rather than an assumed-safe retry.
(Moved from the requirements document at its lint ceiling and compressed at this file's own. No position or receipt was dropped.)
Components and responsibilities
internal/mcp/serverregisters the tool parameter, validates the mode and forwards it. It does not compose content.internal/mcp/handlersunmarshals the mode from the WebSocket payload, re-validates it, and passes it to the plan service. It neither composes content nor reads the plan for composition.internal/task/service.PlanServiceowns composition: it reads the stored content and produces the composed content inside the same serialized region that commits the write.internal/task/repository/sqlite.Repositoryis unchanged.WritePlanRevisionalready commits the HEAD upsert and the revision write in one transaction.
Where composition happens
Composition belongs in PlanService, not the MCP handler.
An append is a read-modify-write. Its read must be ordered against other writes to the
same task, or two concurrent appends read the same stored content, the second overwrites
the first, and a section is lost while both callers receive success —
AC-TASKS-PLAN-APPEND-003.1 and 003.2 forbid exactly that. The MCP handler sits
outside every mechanism that orders plan writes: a handler calling GetPlan,
concatenating, then calling UpdatePlan reads strictly before the write path begins, so
that interleaving is not merely possible there, it is unprotected by construction.
Passing the mode and fragment down to the service is what makes 003.1 achievable at all.
This departs deliberately from the originating task description, which proposed composing
in handleUpdateTaskPlan before calling planService.UpdatePlan. That placement cannot
satisfy REQ-TASKS-PLAN-APPEND-003, and contradicts the same description's own
requirement that the append run under the per-task lock — the lock is inside the service,
that site above it. This design resolves the conflict in favor of the guarantee.
UpdatePlanRequest carries the mode.
Which read composes matters. upsertPlan already performs exactly one HEAD read —
readPlanHead, inside the lock — and resolveHeadFallbacks reuses that value for
title, author and missing-plan resolution. Compose from that read. It sits in the
serialized region and observes the HEAD row the write then commits against, which is what
003.1 requires. A second read would reintroduce the straddling this section exists to
prevent and must not be added; title, author and missing-plan resolution keep today's
values, which AC-TASKS-PLAN-APPEND-002.6 and 005.1 fix.
Serialization dependency
An append is a read-modify-write, so REQ-TASKS-PLAN-APPEND-003 holds only if plan
writes for a task are serialized. This section states what that must cover and why this
capability does not build it.
What the serialization must cover
Every plan write for that task id must take the same per-task lock, not appends
alone. An append-only lock cannot satisfy AC-TASKS-PLAN-APPEND-003.4: a replace not
taking the lock can commit between an append's read and its commit, after which the
append writes stored-plus-fragment and the replace is discarded. Both callers are told the
write succeeded and the surviving content matches neither serial order — a lost update,
with a replace rather than a second append as the other party.
The lock already covers the replace path, which does not conflict with
AC-TASKS-PLAN-APPEND-005.1: that freezes replace's observable behavior (content,
title, revision numbering and coalescing, truncation reporting, events, response shape),
and internal serialization changes none of them.
The mechanism is blocking, not optimistic, per
concepts/optimistic-vs-pessimistic-concurrency.md (Agrawal, Carey and Livny 1987:
blocking beats optimistic restart at medium-to-high contention under finite resources).
Compare-and-retry also fails for a second reason: a retry would re-append, and
AC-TASKS-PLAN-APPEND-003.6 states an append is not idempotent.
The primitive already exists — do not rebuild it
plan-write-consistency landed: internal/task/service/plan_lock.go provides
planLockTable, a per-task-id mutex table retiring entries by waiter count and returning
an idempotent release, held by PlanService. Build must not implement a second lock.
Two invariants are non-obvious: the waiter count is incremented while the outer map mutex is still
held, so a concurrent release cannot delete an entry from under an acquirer en route to
it; and release is idempotent, so a deferred panic-safety release can coexist with the
explicit one on the success path. Getting either wrong fails rarely and silently — the
worst outcome for a concurrency primitive.
The precondition, and how Build confirms it
Met on origin/main as of 2026-09-05. Build confirms it against a closed list of
paths, not the open phrase "all of its write paths", which is not decidable as written.
Serialization is present when every path performing a Plan write — per
## Terminology, upserting HEAD and writing or merging a revision — takes the
per-task lock around both its read and its commit. There are exactly four, all taking it
today: PlanService.CreatePlan, UpdatePlan, DeletePlan, and the revision revert path.
PlanService.MarkImplementationStarted is deliberately not on that list and its
absence does not fail the check. It issues a targeted UPDATE task_plans under no lock,
but it is not a Plan write: it writes no revision and touches neither content nor
title, setting only implementation_started_at, ..._session_id, ..._by and
updated_at. Only the last overlaps the HEAD upsert's ON CONFLICT set, so the worst a
race does is leave updated_at reflecting whichever statement committed second — a
timestamp, not content, so no composed section is lost. Build records this path as
unlocked and proceeds. The rule generalizes: a PlanService method writing no revision
and no content-bearing column is out of scope even though it writes task_plans.
If a future base fails this check, stop and route for a scheduling decision: do not build a temporary lock, and do not ship the append path unserialized.
Alternative considered and rejected
Composing inside WritePlanRevision's write transaction would let SQLite's transaction
serialization stand in for the lock, removing the precondition. Rejected: it moves
Markdown composition policy into the SQL repository layer, which this design assigns to
PlanService; it changes the repository interface the precondition branch also changes,
trading one collision for another; and it substitutes a different
concurrency mechanism for the blocking one ## Prior art reasoned about. Recorded so
it is not re-derived.
Mode parameter and validation
The mode is an optional string. Its description names replace and append. The
registration does not use mcp.Enum, because the generic argument validator would
intercept an invalid value before the handler can return the required error. The
handler performs exact, case-sensitive validation and names both accepted values.
Validation is an exact, case-sensitive match against the two values. An empty or absent
value means replace. Any other is a validation error naming both accepted values, and
no write occurs. The handler must not fall back to replace: that is the path by which
a typo overwrites a plan with a fragment.
AC-TASKS-PLAN-APPEND-001.7 puts mode validity before task-reach authorization,
inverting the order validatePlanWrite uses for the size limit, where authorization
runs first so an unreachable task's write is refused as unreachable, not as oversized.
The mode differs in kind: its accepted values are published in the tool's own schema,
so rejecting an unrecognized one discloses nothing about the target task and belongs at
the edge. Every later check keeps the existing order, so the size limit's placement
relative to authorization is unchanged.
Three sites carry the value, and the second is not named in the originating task description:
internal/mcp/server/server.go— theupdate_task_plan_kandevregistration.internal/mcp/server/handlers.go—updateTaskPlanHandler, which builds the WebSocket payload. It copiestask_id,content,created_byand an optionaltitle. A mode not copied here never reaches the handler, and the tool silently replaces instead of appending — the destructive default.copyOptionalStringArgis the existing helper.internal/mcp/handlers/handlers.go—handleUpdateTaskPlan, whose request struct has noModefield today, so an unmarshalled mode is silently dropped until one is added. It then calls the service.
The two sibling surfaces, which behave differently on purpose
AC-TASKS-PLAN-APPEND-005.2 and AC-TASKS-PLAN-APPEND-005.3 resolve a hazard that
exists today: both sibling write surfaces silently drop an unknown field, and one of
them is destructive if it does.
-
create_task_plan_kandevrejects a mode.createTaskPlanHandlerbuilds an explicit payload map, so a suppliedmodeis dropped before it travels, andCreatePlanupserts. An agent typingmode: "append"on this tool would have its fragment committed as the entire plan, with a success response — precisely whatAC-TASKS-PLAN-APPEND-001.3rejects on the sibling tool. The fix is a validation error atcreateTaskPlanHandlernamingupdate_task_plan_kandev, before any payload is sent.The schema must declare
modeafter all, narrowly. The server's generic MCP argument-schema validator (internal/mcp/server/tool_argument_validation.go) compiles every tool's schema withadditionalProperties:false, derived from the exact sameTool.InputSchemaobjectListTools()exposes to clients, and runs it before any handler body executes. Ifcreate_task_plan_kandev's schema does not declaremode, a call supplyingmode: "append"never reachescreateTaskPlanHandler's own rejection at all: the generic validator intercepts it first with a schema-keyword error that names neither the tool norupdate_task_plan_kandev, which failsAC-TASKS-PLAN-APPEND-005.2's literal requirement. There is no code-only way to let an undeclared top-level key reach a handler under this validator. The resolution (AC-TASKS-PLAN-APPEND-006.9) is to declaremodein the schema with a description stating only that the value is rejected — advisory to a well-behaved client, not an advertised capability — socreateTaskPlanHandler's own, correctly-worded rejection is what the caller actually sees. -
The browser path ignores it.
wsUpdateTaskPlanunmarshals into a struct with noModefield, andjson.Unmarshalignores unknown fields — the path's existing behavior for every unknown field, so leaving it is what keepsAC-TASKS-PLAN-APPEND-005.3's "unchanged" true. Build adds nothing here. The asymmetry with the bullet above is deliberate: a mistyped mode is possible on an agent-facing tool and not on this transport. Recorded so it is not "corrected" into symmetry later.
Separator normalization
Defined so that a fragment beginning with a Markdown heading always begins a line,
whatever the stored content ends with. AC-TASKS-PLAN-APPEND-002.2 requires exactly
one blank line between the two bodies.
"Whitespace" here is the requirements' normative definition: a character whose Unicode
White_Space property is true. In Go that is unicode.IsSpace, and so
strings.TrimSpace plus strings.TrimLeftFunc/TrimRightFunc with a
unicode.IsSpace predicate. A hand-rolled " \t\r\n" cutset does not satisfy the contract: it
misses U+0085 NEL and U+00A0 no-break space, so it would classify the same fragment
differently from AC-TASKS-PLAN-APPEND-001.5's validation, which uses this definition.
Use one helper for both so they cannot drift.
Given stored S and fragment F:
-
Let
S'beSwith trailing whitespace removed. -
Let
F'beFwith its leading empty and whitespace-only lines removed. Leading spaces and tabs on the fragment's first non-empty line are preserved, because they can be significant in Markdown — inside a list item, or an indented code block.Removing lines rather than only leading line-ending characters is what
AC-TASKS-PLAN-APPEND-002.2andAC-TASKS-PLAN-APPEND-002.3require, and the two differ. ForS = "previous"andF = " \n## New section"— valid, sinceAC-TASKS-PLAN-APPEND-001.5rejects only fragments that are whitespace throughout — stripping leading line endings removes nothing, becauseFbegins with spaces. The result would carry both the inserted empty line and the fragment's own whitespace-only first line: two blank lines between the bodies, breaking "exactly one empty line". Deciding per line removes that first line while leaving any indent on## New sectionuntouched. -
If
S'is empty, the composed content isF'. -
Otherwise the composed content is
S'+"\n\n"+F'.
Trailing characters of F are preserved exactly. Nothing is appended after the
fragment, so a caller ending without a newline gets a document ending without one; a
later append still separates correctly because step 1 normalizes the tail then.
Both steps act on the outer edges only: no interior character of either body is
examined or altered, which is what AC-TASKS-PLAN-APPEND-002.5 requires.
This algorithm composes S' and F', not S and F, which is exactly what
AC-TASKS-PLAN-APPEND-002.1 permits — it forbids insertion, reordering and removal
beyond the edge whitespace its siblings mandate, and steps 1 and 2 are those mandated
removals. A test written against AC-TASKS-PLAN-APPEND-002.1 must therefore assert
composed == S' + "\n\n" + F', never composed == S + "\n\n" + F; the latter
contradicts AC-TASKS-PLAN-APPEND-002.2 for the example above and would be a wrong
test, not a found bug.
Line endings: a line is terminated by \n or \r\n, and a carriage return counts as
whitespace for steps 1 and 2, so a CRLF document or fragment is trimmed correctly at
both edges. The separator at step 4 is always \n\n, so appending to a CRLF document
yields mixed line endings. That is accepted: plans are agent-written Markdown, every
consumer reads them as text, and normalizing a document's line endings would rewrite
interior characters AC-TASKS-PLAN-APPEND-002.5 forbids touching.
Size limit placement
plan-content-size-limit landed first, and its admission check sits in the one place an
append makes wrong. PlanService.validatePlanWrite runs from both CreatePlan and
UpdatePlan before s.locks.acquire, measuring len(req.Content) against
MaxPlanContentBytes. For a replace that is correct and deliberate: the submitted content
is what will be stored, so the cheap check runs before the lock and an oversized write
never queues. For an append it is wrong twice over. The submitted content is the fragment,
so a 2 KiB fragment onto a 255 KiB plan passes a check the 257 KiB result would fail — the
limit is silently bypassed. And the composed content does not exist at that point, the
stored content not having been read.
The resolution is a split, not a move:
- Replace keeps the pre-lock check exactly as it is.
AC-TASKS-PLAN-APPEND-007.5freezes its value, position and error. Moving it inside the lock for both modes would make every oversized replace take the lock first — a regression in a path this capability is not here to change. - Append is measured after composition, inside the lock. The composed content is
produced in
upsertPlan, already under the per-task lock, so the check belongs immediately after composition and beforeWritePlanRevision. That satisfiesAC-TASKS-PLAN-APPEND-007.4: the value admitted is the value committed, against the same stored content the write composed from. - Build must not also leave the pre-lock check active for
append. The limit is evaluated exactly ONCE on that path. Kept as a cheap early reject it measuresreq.Content— the fragment — so a fragment that alone exceeds the limit is refused before any composed content exists and the caller is shown the fragment's size, whichAC-TASKS-PLAN-APPEND-007.3forbids without qualification. Skip only the size comparison:validatePlanWrite's authorization still runs for both modes, and itsGetTasklookup sits inside the oversized branch, so skipping the comparison skips that too. Order perAC-TASKS-PLAN-APPEND-001.7.
Reuse checkPlanContentSize rather than re-deriving it, so both modes share one limit
and one error type. The rejection must report the composed size
(AC-TASKS-PLAN-APPEND-007.3): PlanContentTooLargeError.Submitted carries the number
the agent sees, and reporting the fragment's would tell a caller that 2 KiB exceeded
256 KiB, which is unactionable.
One consequence Build must not miss: the append's check runs after the lock is taken, so
an oversized append returns having read and locked, and must still leave the stored plan
untouched and write no revision (AC-TASKS-PLAN-APPEND-007.2). Returning the error before
WritePlanRevision makes that true, since HEAD and the revision are written in that one
call. An oversized append is not a truncation; the detector is already skipped here per
Guard placement.
Guard placement
Truncation detection runs on the replace path only. It must not run for an append.
The site is planTruncationDetected in internal/task/service/plan_truncation.go,
reached from PlanService.UpdatePlan inside the per-task lock, gated by the
request's EvaluateTruncation flag. evaluatePlanWriteGuard no longer exists;
internal/mcp/handlers/task_plan_guard.go now only renders planTruncationWarning
from the returned ReplacedRunes/NewRunes. Skip the detector at the service, not
the handler.
Skipping it is a correctness requirement, not an optimization. The guard flags a write retaining less than half the stored document's characters, and an append's fragment is routinely a small fraction of the stored plan, so it would fire on the common case, warn about content that was not lost, and force a revision split. The composed content is never a truncation of the stored content, because it contains it in full.
Two consequences. The append path must not set ForceNewRevision on the guard's
account, which keeps appends eligible for the existing coalescing rules
(AC-TASKS-PLAN-APPEND-004.2); and its response must not be wrapped by
planWritePayload, so the shape stays the unwrapped plan DTO a non-truncating write
returns today (AC-TASKS-PLAN-APPEND-004.3). Those criteria are stated as observable
outcomes rather than as a named call, so they hold wherever the detector lives.
Failure and recovery
A failed read of the stored content is fatal to an append and must abort the write with
the stored plan untouched (AC-TASKS-PLAN-APPEND-003.5). This differs deliberately from
replace, where a failed read is non-fatal: there the caller supplied the whole document,
so the read only judges the write. On append the read is an input to the content itself,
and proceeding without it would commit the fragment as the entire document — the
destruction the mode exists to prevent.
AC-TASKS-PLAN-APPEND-003.5 also fixes how that failure is reported: distinguishably
from plan-not-found, and with no storage detail. The state Build needs already exists —
readPlanHead returns planHeadUnknown when the read fails and planHeadAbsent when
there is no plan, and its doc comment says that split is deliberate — but upsertPlan
acts only on planHeadAbsent today, so planHeadUnknown falls through into the write.
Build adds an append branch on planHeadUnknown and must not report it as
ErrTaskPlanNotFound. That is the nearest error to hand and
AC-TASKS-PLAN-APPEND-001.6 pins it to the adjacent state, which is exactly the trap: a
caller told its plan is missing calls create_task_plan_kandev, which upserts and
commits the fragment as the entire plan — the destruction this mode exists to prevent,
reached through the error message. Use a distinct sentinel and map it in
planws.UpdateError, which otherwise falls back to a generic internal error.
PlanService.UpdatePlan authorizes the task before it reads anything, so composing in
the service keeps the append's read behind the same task-reach check that guards a
replace (AC-TASKS-PLAN-APPEND-005.6). Composing in the handler would put a
plan-content read on a path that must re-derive that check for itself.
Appending to a task with no plan is rejected with the existing plan-not-found outcome:
UpdatePlan already returns ErrTaskPlanNotFound when HEAD is absent and
planws.UpdateError maps it. Append does not create a plan, so
create_task_plan_kandev remains the only way to make one. The missing-task case is
unchanged and stays owned by the plan write lifecycle design: the foreign key on the
HEAD upsert produces ErrTaskNotFound and the transaction rolls back.
Unchanged surfaces
The browser write path (wsCreateTaskPlan, wsUpdateTaskPlan), the revert path, the
plan DTO, the plan editor and revision history keep their current behavior. The browser
path reaches PlanService through different handlers and never supplies a mode, so it
takes the replace default. create_task_plan_kandev is not on this list: what it
stores is unchanged, but it gains a mode rejection (AC-TASKS-PLAN-APPEND-005.2) and a
corrected content description (006.9).
Agent-facing description and acknowledgement
REQ-TASKS-PLAN-APPEND-006 — which agent-facing strings must change, the class rule
AC-TASKS-PLAN-APPEND-006.9 binds them under, the truncation warning's rewrite, the
acknowledgement's unchanged behavior and the description tests that pin all of it — is
specified in the agent text design. It was split
out when this file reached its lint ceiling; no content was dropped in the move.
Test strategy
Backend tests alongside the sources. Service tests cover composition and are where the separator table belongs: a stored document ending with no newline, with one, and with several, each against a fragment beginning with a heading; an empty and a whitespace-only stored document; a fragment with leading blank lines and one with a leading indent; and preservation of the fragment's trailing characters.
Two concurrency tests, both driving overlapping writes from separate goroutines rather than in sequence, both under the race detector.
- Two appends to one task: both fragments present afterwards. Must fail against an implementation that composes outside the serialized region.
- An append concurrently with a replace: the result equals one of the two serial
outcomes — the replace's content alone, or that content followed by the appended
fragment — and never the pre-replace content plus the fragment, the lost update
AC-TASKS-PLAN-APPEND-003.4forbids. Without it an append-only lock passes the whole suite while violating that criterion, so this is the test that pins the serialization's scope rather than merely its existence.
Handler tests cover mode validation: absent, empty, both accepted values, an
unknown value, and a case variant. Rejections assert the stored plan and revision count
are unchanged, not merely that an error was returned. A further test pins
AC-TASKS-PLAN-APPEND-001.7: a request invalid on two counts reports the earlier
failure in that criterion's order.
A read-failure test forces the stored-content read to fail and asserts the append is
rejected with an error the caller can tell apart from plan-not-found, that no revision is
written and the stored plan is byte-identical afterwards
(AC-TASKS-PLAN-APPEND-003.5). A guard test asserts an append of a small fragment onto a plan large enough to trip
the truncation thresholds emits no warning, forces no new revision, and returns the
unwrapped shape. A bridge test asserts a mode supplied to the MCP tool
reaches the WebSocket payload, so site 2 cannot be omitted silently.
Size-limit tests, against MaxPlanContentBytes rather than a copied literal:
- an append whose fragment is far below the limit but whose composed content
exceeds it is rejected, with the stored plan and revision count unchanged
(
AC-TASKS-PLAN-APPEND-007.1,007.2). This fails against an implementation that measures the fragment, and is the reason that section exists. - the error reports the composed size, not the fragment's (
007.3). - an append whose fragment alone exceeds the limit is likewise rejected reporting
the composed size (
007.3). This is the test that fails against an implementation which adds the post-composition check but also leaves the pre-lock one active forappend; the first bullet passes there. - an oversized replace is still rejected before the lock, with its existing error
(
007.5).
Sibling-surface tests: create_task_plan_kandev with any mode is rejected and
stores nothing (AC-TASKS-PLAN-APPEND-005.2); the browser update path with an unexpected
mode field behaves as without it (005.3).
A whitespace-boundary test covers a fragment whose only non-" \t\r\n" characters
are U+00A0 or U+0085: AC-TASKS-PLAN-APPEND-001.5 must reject it, pinning the Unicode
definition against a hand-rolled cutset.
Replace-mode regression tests already exist and must continue to pass unmodified,
including the truncation-guard tests. A change to one signals that
AC-TASKS-PLAN-APPEND-005.1 has been broken.