Files
kandev/docs/specs/tasks/system-design/plan-write-append-mode.md
T
2026-09-06 15:10:10 +01:00

496 lines
29 KiB
Markdown

---
status: draft
system: tasks
requirements:
- REQ-TASKS-PLAN-APPEND-001
- REQ-TASKS-PLAN-APPEND-002
- REQ-TASKS-PLAN-APPEND-003
- REQ-TASKS-PLAN-APPEND-004
- REQ-TASKS-PLAN-APPEND-005
- REQ-TASKS-PLAN-APPEND-006
- REQ-TASKS-PLAN-APPEND-007
created: 2026-09-01
owners:
- kandev
---
# 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](#size-limit-placement) exists to account for.
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-TASKS-PLAN-APPEND-001` | [Mode parameter and validation](#mode-parameter-and-validation) |
| `REQ-TASKS-PLAN-APPEND-002` | [Separator normalization](#separator-normalization) |
| `REQ-TASKS-PLAN-APPEND-003` | [Where composition happens](#where-composition-happens), [Serialization dependency](#serialization-dependency) |
| `REQ-TASKS-PLAN-APPEND-004` | [Guard placement](#guard-placement) |
| `REQ-TASKS-PLAN-APPEND-005` | [Unchanged surfaces](#unchanged-surfaces) |
| `REQ-TASKS-PLAN-APPEND-006` | [Agent text design](plan-write-append-mode-agent-text.md) |
| `REQ-TASKS-PLAN-APPEND-007` | [Size limit placement](#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](#what-the-serialization-must-cover).
- `concepts/agent-replay-non-idempotence.md` — re-running an author is not idempotent.
Applied in `AC-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/server` registers the tool parameter, validates the mode and forwards
it. It does not compose content.
- `internal/mcp/handlers` unmarshals 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.PlanService` owns composition: it reads the stored content and
produces the composed content inside the same serialized region that commits the write.
- `internal/task/repository/sqlite.Repository` is unchanged. `WritePlanRevision`
already 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:
1. `internal/mcp/server/server.go` — the `update_task_plan_kandev` registration.
2. `internal/mcp/server/handlers.go` — `updateTaskPlanHandler`, which builds the
WebSocket payload. It copies `task_id`, `content`, `created_by` and an optional
`title`. A mode not copied here never reaches the handler, and the tool silently
replaces instead of appending — the destructive default. `copyOptionalStringArg` is
the existing helper.
3. `internal/mcp/handlers/handlers.go` — `handleUpdateTaskPlan`, whose request struct
has no `Mode` field 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_kandev` rejects a mode.** `createTaskPlanHandler` builds an
explicit payload map, so a supplied `mode` is dropped before it travels, and
`CreatePlan` upserts. An agent typing `mode: "append"` on this tool would have its
fragment committed as the entire plan, with a success response — precisely what
`AC-TASKS-PLAN-APPEND-001.3` rejects on the sibling tool. The fix is a validation
error at `createTaskPlanHandler` naming `update_task_plan_kandev`, before any payload
is sent.
**The schema must declare `mode` after all, narrowly.** The server's generic MCP
argument-schema validator (`internal/mcp/server/tool_argument_validation.go`) compiles
every tool's schema with `additionalProperties:false`, derived from the exact same
`Tool.InputSchema` object `ListTools()` exposes to clients, and runs it **before** any
handler body executes. If `create_task_plan_kandev`'s schema does not declare `mode`,
a call supplying `mode: "append"` never reaches `createTaskPlanHandler`'s own
rejection at all: the generic validator intercepts it first with a schema-keyword
error that names neither the tool nor `update_task_plan_kandev`, which fails
`AC-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 declare `mode` in the schema with a description
stating only that the value is rejected — advisory to a well-behaved client, not an
advertised capability — so `createTaskPlanHandler`'s own, correctly-worded rejection
is what the caller actually sees.
- **The browser path ignores it.** `wsUpdateTaskPlan` unmarshals into a struct with no
`Mode` field, and `json.Unmarshal` ignores unknown fields — the path's existing
behavior for *every* unknown field, so leaving it is what keeps
`AC-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`:
1. Let `S'` be `S` with trailing whitespace removed.
2. Let `F'` be `F` with 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.2` and `AC-TASKS-PLAN-APPEND-002.3` require, and the two
differ. For `S = "previous"` and `F = " \n## New section"` — valid, since
`AC-TASKS-PLAN-APPEND-001.5` rejects only fragments that are whitespace *throughout*
— stripping leading line endings removes nothing, because `F` begins 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 section` untouched.
3. If `S'` is empty, the composed content is `F'`.
4. 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.5`
freezes 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 before `WritePlanRevision`. That satisfies
`AC-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 measures
`req.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, which
`AC-TASKS-PLAN-APPEND-007.3` forbids without qualification. Skip only the size comparison:
`validatePlanWrite`'s authorization still runs for both modes, and its `GetTask` lookup
sits inside the oversized branch, so skipping the comparison skips that too. Order per
`AC-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).
## 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](plan-write-append-mode-agent-text.md). 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.4` forbids. 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 for
`append`; 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.