Files
kandev/docs/specs/tasks/system-design/plan-write-consistency.md
T
2026-09-01 18:49:54 +00:00

540 lines
32 KiB
Markdown

---
status: current
system: tasks
requirements:
- REQ-TASKS-PLAN-WRITE-CONSISTENCY-001
- REQ-TASKS-PLAN-WRITE-CONSISTENCY-002
- REQ-TASKS-PLAN-WRITE-CONSISTENCY-003
- REQ-TASKS-PLAN-WRITE-CONSISTENCY-004
- REQ-TASKS-PLAN-WRITE-CONSISTENCY-005
created: 2026-08-31
owners:
- kandev
---
# Task plan write consistency System Design
## Purpose and boundaries
This design closes the window between a plan read and its commit. It does not
change the truncation thresholds, the response
shape of a non-truncating write, or the missing-task boundary
owned by `docs/specs/tasks/system-design/plan-write-lifecycle.md`.
## Requirement mapping
Ids below abbreviate `REQ-TASKS-PLAN-WRITE-CONSISTENCY-00N`.
| Requirement | Design section |
| --- | --- |
| `001` | Chosen mechanism; Where the decision moves |
| `002` | Chosen mechanism; Retiring lock keys; Scope of the critical section; Existing behavior that must change |
| `003` | Scope of the critical section |
| `004` | Response contract |
| `005` | Scope of the critical section; Retiring lock keys; Failure and recovery |
## Current state
- Agent path: `internal/mcp/handlers.handleCreateTaskPlan` and
`handleUpdateTaskPlan` call `evaluatePlanWriteGuard`, then
`PlanService.CreatePlan` / `UpdatePlan` with `ForceNewRevision` from the guard.
- Browser path: `internal/task/handlers.wsCreateTaskPlan` and `wsUpdateTaskPlan`
call the same methods and never set `ForceNewRevision`.
Before one agent update commits, plan state is read at least four times outside any
transaction: `evaluatePlanWriteGuard` reads `GetPlan` and the revision history,
`UpdatePlan` reads `GetTaskPlan` for its existence check, and `upsertPlan` reads
`GetTaskPlan` and `GetLatestTaskPlanRevision` again. All use the read-only pool,
opened by `internal/db.OpenSQLiteReader` with four connections, while the commit
happens later in `WritePlanRevision` on the single-connection writer pool.
`SetMaxOpenConns(1)` serializes commits against each other but not a reader against a
commit, so read(A), read(B), commit(B), commit(A) is unprevented.
`WritePlanRevision` already computes `max(revision_number) + 1` in its transaction.
The coalesce target and truncation decision are not safe.
## Chosen mechanism
Serialize the whole read, decide, commit sequence per task inside `PlanService`,
and evaluate truncation inside that critical section.
`PlanService` gains a per-task lock keyed by task ID, held across the state reads,
the truncation decision, the coalesce decision, the `WritePlanRevision` call and the
following re-read. Because every plan writer already funnels through
`PlanService`, taking the lock in the four public entry points, `CreatePlan`,
`UpdatePlan`, `RevertPlan` and `DeletePlan`, covers every path that writes a revision
or reads plan state to decide one; "Scope of the critical section" gives the boundary
and explains why the shared `upsertPlan` helper is not the acquisition point. Keying
by task ID satisfies `AC-TASKS-PLAN-WRITE-CONSISTENCY-002.6`: a write for one task
never waits on another's lock.
`PlanService.MarkImplementationStarted` is the one exported method that mutates a
`task_plans` row and is deliberately left outside the lock rather than overlooked: a
single atomic statement that reads no plan state and writes no revision, so it can
neither observe nor invalidate a decision made inside the section. It shares one
column with `upsertPlanHead`'s `ON CONFLICT` clause, `task_plans.updated_at`, and
writes it only on the first mark. The overlap is immaterial: no criterion constrains
the HEAD row's `updated_at`, and `canCoalesce` reads the *revision's* `UpdatedAt` on
`task_plan_revisions`, a different table.
### Retiring lock keys
Task identifiers are unbounded over a process lifetime, so entries must be
removed. Neither keyed-mutex precedent removes them: `parentMutex` in
`internal/task/service/handoff_service.go` and `keyedMutex` in
`internal/plugins/service.go`, the latter because its plugin id keyspace is "small
and long-lived". Task ids are neither, so neither may be copied without retirement.
Retirement has one silent failure mode. Delete an entry whenever its mutex is
released and a goroutine holding the old pointer locks that instance while a later
arrival locks a fresh one for the same task: two writers, no error, the race back.
The protocol that avoids it: one outer mutex guards the map, and each entry holds its
per-task mutex plus a count of goroutines holding or waiting for it. Acquire takes
the outer mutex, finds or creates the entry, increments the count, releases it, then
locks the entry mutex. Release unlocks the entry mutex, takes the outer mutex,
decrements the count, deletes the entry only at zero, and releases the outer mutex. Because the count is incremented before the outer mutex is released, no entry
can be deleted while a goroutine is still on its way to it.
Any implementation keeping both properties is acceptable: the count is mutated only
under the map guard, and deletion happens only at zero under it.
Within the section the reads see the state the write will commit against: no other
in-process writer can commit while the lock is held.
That rests on a second property the whole mechanism depends on: a read issued on the
read-only pool inside the lock must observe a commit made moments earlier on the
writer pool. It holds because the database runs in WAL mode, where a read transaction
takes its snapshot when it begins and sees everything committed before it started.
`_cache=shared` does not provide it; it only governs page-cache sharing.
This is easy to lose: every plan and MCP test helper passes one handle as both writer
and reader and cannot observe it, so `AC-TASKS-PLAN-WRITE-CONSISTENCY-002.10` requires
it over genuinely split pools. See "Test strategy".
Sound only within one process, which the requirements state as an exclusion.
### Where the decision moves
Truncation is decided in `internal/mcp/handlers` today, above the service, so a lock
inside the service cannot cover it. The decision moves into the write path and the
caller selects it rather than performing it:
- The write request carries a flag meaning "evaluate truncation for this write".
The agent path sets it. The browser path does not, which is
`AC-TASKS-PLAN-WRITE-CONSISTENCY-003.2` and `003.4`.
- The write result carries what the caller needs to render a warning;
`PlanWriteResult` below names the fields.
- The MCP handler keeps ownership of the warning string. `planTruncationWarning`
stays in `internal/mcp/handlers` with its "revision 0 means unknown" convention.
An unknown number does not prove that history contains the replaced content.
Therefore, that warning does not claim preservation or recovery. Its signature
changes because it measures the code
points itself today, but the handler no longer holds the replaced content, which
exists only inside the critical section. It takes the counts instead,
`(replacedRunes, newRunes, priorRevisionNumber int)`, and the measurement moves into
the service, onto the content the write replaced.
- The detector moves with the decision. `planTruncationDetected` and its thresholds
`planTruncationMinPriorChars` and `planTruncationMaxRetainRatio` are in
`internal/mcp/handlers` today, and `internal/task/service` cannot import them:
`mcp/handlers` imports `task/service` and no reverse import exists, so reusing them
where the replaced content lives would be a cycle. They move to
`internal/task/service`, keeping the single source of truth the thresholds
exclusion assumes; no copy stays behind. `evaluatePlanWriteGuard` and
`planWriteGuardResult` become dead and are deleted rather than kept as a thin
flag-setter: their two callers set the request flag and read the result.
- The handler performs no plan or revision read after the service call. Rendering
from a re-read would put the race back into the one path the warning exists to make
trustworthy; returning the full prior content would copy a 40k character plan on
every truncating write.
- The renderer assumes `newRunes < replacedRunes` and `replacedRunes > 0`, today
pinned on its only caller invoking it after `planTruncationDetected` returned true.
That carries: the handler renders only when the result's truncation flag is set,
and the service sets it only where the comparison already holds.
`ForceNewRevision` remains for callers that force an append for their own reasons;
the in-section truncation decision also forces one
(`AC-TASKS-PLAN-WRITE-CONSISTENCY-001.5`).
Concretely: `CreatePlanRequest` and `UpdatePlanRequest` each gain one bool beside
`ForceNewRevision`, and `CreatePlan` and `UpdatePlan` return a result struct rather
than `*models.TaskPlan`.
```go
type PlanWriteResult struct {
Plan *models.TaskPlan
TruncationDetected bool
ReplacedRunes int
NewRunes int
PriorRevisionNumber int // 0 means not established
}
```
The two browser call sites read `.Plan` and are otherwise unchanged. This keeps
`AC-TASKS-PLAN-WRITE-CONSISTENCY-004.1` and `003.2` true.
### Scope of the critical section
The lock is acquired in the four public entry points, `CreatePlan`, `UpdatePlan`,
`RevertPlan` and `DeletePlan`, in each case after identifier validation and
authorization and before that method's own first plan-state read.
For `RevertPlan` that first read is the target-revision fetch, not the HEAD read
after it. `mergeRevisionInTx` rewrites a revision row's title and content in place
when it is the coalesce target, so the latest revision is mutable, not immutable
history: a revert fetching that target outside the lock can commit content a
concurrent coalescing write already replaced. `upsertPlan`
does not acquire it; it becomes a helper whose documented contract is that its caller
already holds the task's plan write lock. That is what lets `UpdatePlan` put its
existence check in the same section as the write it guards.
The acquisition point is named because the obvious alternative fails. `UpdatePlan`
reaches `upsertPlan`, so locking in both takes a non-reentrant per-task mutex twice
on one path: every update self-deadlocks, permanently for that task, stranding the
refcount. `AC-TASKS-PLAN-WRITE-CONSISTENCY-005.5` states the invariant: the section
is entered once per call path, so no method that acquires may call another that
acquires.
Authorization runs before acquisition, next to identifier validation. It is a task
lookup wired to `AuthorizeTaskAccess`, so it performs I/O and there is no reason to
hold a task's write lock across it; this is also today's order.
The section ends after the post-write re-read, not at `WritePlanRevision`'s return.
`upsertPlan` and `RevertPlan` both re-read HEAD once the write has committed, and in
`upsertPlan` that re-read, not the in-memory value the write was assembled from, is
what the method returns. Released at the commit, that read could observe a queued
write's commit instead and hand the caller another write's content as the outcome of
its own, which `AC-TASKS-PLAN-WRITE-CONSISTENCY-002.5` forbids in the same response
`004.2` pairs with the truncation counts. It is kept rather than replaced by the
in-memory value because it also carries the persisted HEAD identity when the
pre-write read failed. See "Existing behavior that must change".
`DeletePlan` is in that list deliberately. Moving `UpdatePlan`'s existence check
inside the section is only worth doing if the deleter participates: an unlocked
delete can still commit between an in-lock check and the write it guards, so moving
the check alone changes nothing. `DeletePlan` writes no revision, so it is not a plan
write under the requirements' Terminology, but it mutates state every plan write
reads.
`AC-TASKS-PLAN-WRITE-CONSISTENCY-002.12` states the ordering this buys.
With the deleter inside, `UpdatePlan`'s existence check can move in too. It currently
reads `GetTaskPlan` before delegating to `upsertPlan`, which reads it again; the
redundant second read should collapse into one taken under the lock. Collapsing it
changes what a failed read must do, which "Existing behavior that must change"
settles.
The two reads it collapses serve opposite contracts and the surviving read keeps
both. `CreatePlan` tolerates an absent HEAD and appends
(`AC-TASKS-PLAN-WRITE-CONSISTENCY-001.8`, `002.11`); `UpdatePlan` must fail on one
(`002.12`) rather than recreate a plan a concurrent delete removed. The requirement
is therefore caller-supplied, not inferred from the read: `upsertPlan` takes a
`requireExistingHead` argument, set by `UpdatePlan` and clear on the `CreatePlan`
path, and returns `ErrTaskPlanNotFound` when it is set and the read returned
`absent`. `unknown` is not `absent` and never triggers it, per `001.6`. The same flag
marks the update path, so the HEAD-row title and author fallbacks stay gated to it
and `CreatePlan`'s defaults are unchanged (`004.1`). The `001.9` preserve flags are
not gated to it: they are computed on both paths, because a create over an existing
plan can omit a title too. Any equivalent shape is acceptable, including hoisting the
read into the entry points and passing the tri-state down; a single tolerant read
with no branch is not.
Serializing the delete does not close the loss path behind it. `DeletePlan` removes
only the HEAD row and leaves the revisions. A create that follows finds no HEAD,
emits no warning (`AC-TASKS-PLAN-WRITE-CONSISTENCY-001.8`), and under today's
coalesce rules can still merge into a surviving revision, overwriting pre-delete
content sequentially rather than by a race. `002.11` closes it by requiring an append
whenever the task has no HEAD at commit time.
The event publishes at the end of `upsertPlan` and `RevertPlan` should not hold the
lock: they call the event bus, which widens the section for no correctness benefit.
That is a restructuring, not a comment. The post-write re-read and both publishes sit
at the end of `upsertPlan` while the lock is owned by `CreatePlan` and `UpdatePlan`,
so the release point is unreachable from the acquisition point. A `defer release()`
in the entry point, which the panic rule in "Failure and recovery" pushes toward,
holds the lock across both publishes; releasing inside `upsertPlan` instead lets that
defer run a second time, decrementing the refcount twice and deleting an entry
another goroutine waits on, the failure "Retiring lock keys" exists to prevent.
`upsertPlan` stops publishing: it returns the saved plan plus the events its caller
is to emit, and the entry point acquires, calls it, releases, then publishes them in
today's order, plan event before revision event. Release is idempotent, so a deferred
release can coexist with an explicit one. Any implementation is acceptable that holds
three properties: one refcount decrement per acquisition, release on every exit path
including a panic, and no event published under the lock.
`DeletePlan` has no post-write re-read, so that boundary does not locate its section
end: it ends when the delete commits, and its `TaskPlanDeleted` publish moves outside
under the same rule.
### Existing behavior that must change
Five things in `PlanService` are not merely relocated by this design. Each is wrong,
or becomes wrong, against the contract, and none is discoverable above.
`resolveCoalesceWindow` maps a negative configured window to `defaultCoalesceWindow`,
five minutes, so `canCoalesce`'s `s.coalesceWindow <= 0` guard never sees a negative
value and a negative window coalesces normally today, while
`002.8` requires zero or negative to append every
write. The negative branch must stop substituting the default: preserve the value or
clamp it to zero, either of which reaches the guard. Only an absent configuration
falls back to five minutes. Without this an implementer believes `002.8` holds.
`upsertPlan` must append when the task has no HEAD at commit time, per
`002.11`. Today the coalesce decision consults only
the latest revision, so revisions surviving a delete remain eligible merge targets.
The collapsed reads must stop aborting the write. This is the largest of the five and
becomes visible only once the collapse happens. Two reads become one, twice over:
`upsertPlan`'s `GetTaskPlan` with the guard's `GetPlan`, and `upsertPlan`'s
`GetLatestTaskPlanRevision` with the guard's revision lookup. Each pair returns the
same state. But their halves have opposite failure contracts: the guard's reads are
tolerant, a failure forces an append and the write proceeds, which is what makes
`001.6` and `001.7` true, while `upsertPlan`'s return
the error and the write dies.
The merged reads take the tolerant contract:
- A failed HEAD read forces an append, emits no warning, and completes the write
(`001.6`). Truncation cannot be evaluated without the replaced content, and
appending stops a later write coalescing into an unknown prior revision.
- A failed latest-revision read forces an append and completes the write; when
truncation was detected the warning is still emitted without a revision number
(`001.7`). The rule is deliberately unconditioned on the truncation flag: after the
collapse one call serves both paths, and appending is always safe, so `001.7`
becomes an instance of the rule rather than an exception.
Both rules outrank the coalesce rules for that write, and
`005.1` states the precedence: its merge clause is
conditioned on the task having a HEAD and on the write having read it successfully,
so a failed read falls to `001.6` and an absent HEAD to `002.11`, both appending.
Coalesce behavior is unchanged for every write whose reads succeeded.
One consequence of that tolerance is not obvious. A failed HEAD read is not "this
task has no HEAD", and `upsertPlan` cannot tell them apart today: both arrive nil.
They must be distinguished as found, absent or unknown, because that value decides
four things besides truncation: the event type, the HEAD identity, `UpdatePlan`'s
title and author fallbacks, and `UpdatePlan`'s existence gate. Under unknown the
write emits the plan updated event rather than the created one; a spurious create
asserts something false about a task's history. It supplies no HEAD identity, which
is safe as long as the re-read succeeds: `upsertPlanHead` fills an empty `ID` and a
zero `CreatedAt` with a fresh UUID and `now` in Go, unconditionally, on the caller's
own struct, before the statement runs, and its `ON CONFLICT(task_id) DO UPDATE`
clause then writes neither column, so an existing row keeps its real ones and the
caller receives those from the in-section re-read. The fabricated pair survives only
in memory, which is what makes a failed re-read a special case in "Failure and
recovery". An implementer
reusing the nil result for unknown ships a write that succeeds, persists correctly
and emits the wrong event type.
The metadata fallbacks are the least obvious and the most damaging to get wrong.
`UpdatePlan` takes `title` and `created_by` from the HEAD row whenever the request
omits them, and both callers may legitimately omit a title. Under unknown those
fallbacks have nothing to read and today's defaults take over: `upsertPlan`
substitutes the literal `Plan` and the author is re-resolved, so a transient read
failure would silently rename the user's plan and rewrite its author, on a write
`001.6` requires to succeed. The write must instead
leave the stored title and `created_by` as they are: the values it could not read are
the values it must not replace.
Making the `ON CONFLICT` assignments conditional on the value being empty does not
achieve that, because nothing empty reaches the statement. Three substitution sites
exist, two on this path: `upsertPlan` replaces an empty title with `Plan` and
`resolveAuthor` resolves the author before the repository is called, then
`upsertPlanHead` substitutes both again. Emptiness is observable only at the top of
`upsertPlan`, so preservation is captured there and carried down as an explicit
signal: `upsertPlan` computes one preserve flag per column from the HEAD read being
unknown and the request field being empty, before its own substitution runs, and
passes both flags through `WritePlanRevision` to `upsertPlanHead`. Neither
substitution changes; they still supply the insert branch's defaults. The flags bind
into the update branch alone,
`title = CASE WHEN ? THEN task_plans.title ELSE excluded.title END` and likewise for
`created_by`, so a task with no HEAD row still inserts the default while an existing
row keeps its stored value. Only `content` and `updated_at` are written
unconditionally. After the upsert, the same transaction reads the stored title and
author. The repository returns these values in the HEAD object. It also uses the
stored title in the new revision. Thus, a later revert cannot apply a fallback title.
Two consequences. `WritePlanRevision` is on the exported `PlanRepository` interface,
so this changes it and every implementation; "Where the decision moves" counts
them. And all four production entry points default `CreatedBy` before
`PlanService` is entered, `agent` on MCP and `user` on the browser, so only the title
half can fire in production today. `001.9` is a service-boundary contract and its
author half is tested there, not through a handler.
`RevertPlan` must stop aborting on its own HEAD read, taken after the target-revision
fetch and whose error it currently returns. A revert writes a revision, so it is a
plan write under the requirements' Terminology and
`001.6` governs it unchanged: an unreadable HEAD
forces an append, emits no warning, and the revert completes. This read is not half
of either collapsed pair, which is why it survives a fix aimed only at those.
`CreatePlan` must validate its identifier before acquiring. The empty-task-id check
lives inside `upsertPlan` today and `CreatePlan` reaches it only through that helper,
so acquiring in the entry point would lock on the empty string and only then reject
the request, serializing every malformed request against every other, which
`005.4` forbids. The check moves up; the other three
already validate first.
## Alternatives considered
**Move truncation detection and the force-append decision inside
`WritePlanRevision`'s transaction.** Robust across processes, because the decision is
made from state read inside the write transaction. Rejected because it puts an
agent-facing presentation heuristic (the 2000 character floor, the retain ratio, code
point counting) into the SQLite repository, and because the coalesce decision would
have to move there too or a second window stays open. Its `WritePlanRevision` change
no longer separates the two: the chosen mechanism changes that method as well.
**Return the written revision number and derive the preceding revision as
`written - 1`.** Rejected as insufficient rather than wrong. It repairs `001.2` in
the warning path, where the write is always an append so `written - 1` really is the
preceding revision. It does not repair `001.1`, `001.3` or any of `002`, whose
decisions are still made from state read before the commit. It closes one symptom, not the race.
## Response contract
No change for a write that emits no warning, satisfying `AC-TASKS-PLAN-WRITE-CONSISTENCY-004.1`.
`planWriteResponse` still embeds `*dto.TaskPlanDTO` and `dto.TaskPlanDTO` is
untouched, so the browser plan editor is unaffected. A truncating agent write still
adds `plan_write_warning` and, when established, `prior_revision_number`.
## Failure and recovery
- Current plan content unreadable, or the preceding revision number unestablished:
append and complete the write, with no warning in the first case and, in the
second, a warning without a revision number when truncation was detected
(`001.6`, `001.7`). Revision numbers start at 1, so
0 still means unknown. Both rules, and why the append is unconditioned on
truncation, are in "Existing behavior that must change"; neither is today's
`upsertPlan` behavior, which aborts.
- Post-write re-read fails, or succeeds reporting no row, after the write has
committed: report the success (`AC-TASKS-PLAN-WRITE-CONSISTENCY-005.8`). Both
reach the service as a nil plan, and a task
deleted after the commit cascades the HEAD row away, so it is real. The
transaction is durable and cannot be rolled back, so the service
returns the plan it assembled in memory, from the request's content plus whatever
HEAD identity it holds, and publishes the events it would otherwise have published.
Returning the error or `ErrTaskPlanNotFound` instead, which is what `upsertPlan`
does today, tells the caller a committed write failed and sends an agent into the
retry-or-rewrite-from-memory behaviour the warning exists to prevent. When the
pre-write HEAD read was itself unknown the service has no identity to report: the
struct it handed the repository carries only the fabricated pair "Existing behavior
that must change" describes. It clears both before returning, so the caller reads
the identity as unknown rather than as a row that does not exist. The repository
has already resolved the stored title and author in the write transaction. The
fallback result and its event can report that metadata as authoritative.
(`005.8`). This path cannot tell the insert branch apart, so it clears
unconditionally. `RevertPlan` is governed by the same rule and
needs no substitute: it returns the revision it wrote, whose id, number and
timestamps `insertNewRevisionInTx` populated in place before the commit, and
publishes with the same cleared identity.
- Task deleted between validation and commit: unchanged. The foreign key
violation classifies to `ErrTaskNotFound` and the transaction rolls back, per
`plan-write-lifecycle.md`.
- A write that fails must release the lock, including on a panic, so `005.3`
holds. Release must not depend on the happy
path and must run the full protocol above: skipping the refcount decrement leaks an
entry permanently, the bound "Retiring lock keys" exists to hold.
- Identifier validation and authorization run before the lock is taken, so an empty
identifier is never a lock key, such requests do not serialize against each other
(`005.4`), and no write lock is held across an
authorization lookup.
- Only the per-task plan write lock is held inside the section (`005.5`); nothing
in it may acquire a second.
- A caller whose context is cancelled while queued is not released early
(`005.7`). A plain `sync.Mutex` is not
context-aware and stays that way: the section is one set of reads plus one short
transaction, so the wait is already bounded. The write acquires in its turn, fails
when it reaches its write transaction, and releases normally, which `005.3`
covers. Its reads before that transaction run on a context decoupled from the
caller's, `context.WithoutCancel`, so cancellation cannot divert the write into
`001.6`'s unreadable-HEAD path and make it succeed. `001.6` fires on a genuine
read failure only.
## Test strategy
The seam is the repository, not the service: `Handlers.planService` is a concrete
`*service.PlanService`, and `internal/mcp/handlers/task_plan_guard_test.go` already
builds handlers over a wrapped repository
(`newMCPPlanTestHandlersWithFailingRevisionLookup` wraps `sqlite.Repository` and
injects failures through it). The concurrency test required by
`AC-TASKS-PLAN-WRITE-CONSISTENCY-001.3` and `002.1` through `002.3` uses that wrapper
to block a chosen call, so the interleaving is forced rather than raced.
It cannot be written as "block A inside the section, then commit B for the same
task": under this design B cannot commit while A holds the lock, which is the
property being tested. B is observed to queue instead, and the assertions are about
the state each write decided against:
1. Seed a plan large enough to cross the 2000 character floor.
2. Start write A, a truncating agent write, and block it inside the section at a
wrapped repository call, after it has read plan state.
3. Start write B for the same task from the other path. Observe that B has not
committed: it is queued on the same per-task lock.
4. Release A and let it commit, then let B proceed to its own commit.
5. Assert that A's counts describe the content A replaced and the revision it names
holds that content; that B's decisions were made against the state A committed,
not what B could have read before queuing; and that HEAD equals the latest
revision.
Step 3 must go through a write path: committing B through the repository directly
would bypass the lock, exercise a writer class the requirements exclude, and pass
unchanged against an implementation with no serialization.
The pool split matters twice. Existing helpers build the repository with
`sqlite.NewWithDB(sqlxDB, sqlxDB, nil)`, one handle as both writer and reader, and
nothing under `internal/task/` or `internal/mcp/` opens `OpenSQLiteReader` today.
That is why the interleaving must be forced rather than left to pool timing, and why
`AC-TASKS-PLAN-WRITE-CONSISTENCY-002.10` needs its own test over one file with a real
`OpenSQLite` writer and a real `OpenSQLiteReader` reader: without it this design's
read-after-write visibility is asserted by prose alone.
One existing test must be rewired rather than left to pass.
`TestMCPPlanTruncationGuard_RevisionLookupFailureOmitsRevisionNumber` is `001.7`'s
only coverage and injects into `ListTaskPlanRevisions`, the call the collapse
removes; left alone it passes while exercising nothing. Its seam moves to
`GetLatestTaskPlanRevision`. Any test asserting a write aborts on a HEAD read failure
has its expectation inverted: rewire it, do not delete or weaken it. Tests calling
`evaluatePlanWriteGuard` directly move to the service result.
Additional coverage: a write for one task commits while a write for another is held
inside the section (`002.6`); boundaries at exactly 2000 replaced characters and
exactly half retained (`001.4`); a case whose byte and code point lengths differ
(`001.1`); browser path emits no warning and keeps its response shape (`003.2`);
revert appends and does not warn (`003.3`); a write failing inside the section is
followed by a successful write to the same task (`005.3`); a same-author repeat
inside the coalesce window adds no revision while one outside it appends (`005.1`);
a negative configured window appends every write (`002.8`); a delete followed by a
same-author create inside the window appends rather than merging into a surviving
revision (`002.11`); a delete forced to interleave with a write is ordered
(`002.12`); a queued write whose context is cancelled fails and does not block the
next write to that task (`005.7`); a failed HEAD read appends, emits no warning,
completes the write and emits the plan updated event rather than the created one
(`001.6`); a failed latest-revision read appends and completes the write where
truncation was not detected; a revert whose own HEAD read fails appends, emits no
warning and completes; a write that omits a title and whose HEAD read fails leaves
the stored title and `created_by` unchanged rather than substituting `Plan`; a
post-write re-read that fails, and one that reports no row, each returning success
with the revision durable and no plan identity rather than the pair `upsertPlanHead`
generated in memory, once through a create or update and once through a revert; an
unknown-HEAD update that verifies the stored title in HEAD, the new revision, the
write result, and a later revert; a divergent HEAD and latest revision that verifies
the warning names no unverified revision; an environment-gated PostgreSQL case for
the metadata-preservation branches; a queued cancellation that observes the waiter
registration before it cancels the caller; an update whose HEAD read returns absent
fails rather than recreating the plan, while
one whose HEAD read is unknown proceeds; and a revert to the latest
revision forced to interleave with a same-author
coalescing write commits the target content, not the content that write merged in.
## Implementation note: starting state
The parent card calls a related change "already implemented and merged". It is not
merged: the work sits only on `feature/plan-truncation-guar-qd5`, which has been
rebuilt and extended since, so identify it by subject rather than by commit id. On
`origin/main` `evaluatePlanWriteGuard` still calls `PlanService.ListRevisions`, there
is no `GetLatestRevision`, and the false `ListRevisions` doc comment remains. It touches `task_plan_guard.go` and
`plan_service.go`, which this work also touches: confirm its merge state and rebase
rather than reimplement.