Files
pad/cmd
xarmian d201ebb87d fix(server): a successful applier-path PATCH stops answering with the previous content (BUG-2995) (#1320)
* fix(server): a successful applier-path PATCH stops answering with the previous content (BUG-2995)

The applier path writes the row WITHOUT content — the markdown travels
through a connected tab's Y.Doc and reaches items.content on the next
collab-snapshot flush — and the 200 was serialized from that row, so a
caller that PATCHed content and read `content` back saw its own update
missing.

The response now carries the content as SENT, marked
`warnings.content_outcome = applied_pending_flush` in the same vocabulary
`content_not_applied` uses. The marker is what makes the echo honest: the
echoed value is true of the apply and not of the row, and the stored form
is not byte-identical to the sent form even once the flush lands.

Measured rather than assumed (BUG-2995 trail): a jsdom harness driving the
real two halves — setContent() then the editor's markdown storage — found
11 of 14 StarterKit-owned constructs normalise (setext headings, bullet
markers, emphasis characters, list renumbering, blank-line runs).

The row deliberately still lags; this stops the response lying about it.

* test(server): the direct-path leg needs a warnings object to be wrong about (BUG-2995)

The leg asserted "no content_outcome when the content landed in the row"
and a mutant that stamps the marker unconditionally survived it: the
warnings object is only CONSTRUCTED when there is something to say, so
the marker landed inside a block that path never enters. The leg was
asserting a property whose mechanism it could not reach.

Sending an undeclared field gives that request a warnings object, so the
mutant now has somewhere to be wrong. Preconditions are asserted rather
than assumed, and the reason is in a comment so it survives a cleanup.

* test: say what each BUG-2995 guard actually establishes (codex round 1)

Two findings, both about a test claiming more than it measures.

The direct-path control leg was named DirectPath and asserted only that
items.content holds the sent value — true of the direct write, and equally
true of contentRouteFallThrough or an ordinary non-routed write. Renamed
to NoPendingMarkerWhenTheContentIsInTheRow, which is the route-independent
invariant it does pin, with the boundary in the doc comment.

The round-trip guard mixed the property (the sent form is not what comes
back) with the recorded serializer output. A tiptap bump could change HOW
a construct normalises without touching the contract and fail the property
tests. Split: per-construct inequality assertions carry the contract, one
clearly-named test holds the recorded forms and says to re-record it.

* docs+cli: tell callers where the content is when a write is pending flush (codex round 2)

The contract was implemented and typed but invisible to the surfaces
callers actually read: the MCP instructions and catalog said only that
'content' is optional, the CLI printed nothing, and the TypeScript mirror
of ItemWriteWarnings had no content_outcome, so typed code could not reach
a warning the server was already sending.

- pad item create/update now print a stderr line when the write is pending
  flush, saying the content landed in the live document, that what was
  printed is what was sent, and that a read may still show the previous
  content. Stderr, never stdout: --format json is piped into scripts.
- pad item update --help, internal/mcp/instructions.md and the catalog's
  update action say the same, including the part that matters most to an
  agent: a get inside the window answers with the PREVIOUS content, which
  is the lag rather than a failed write, so do not re-send on it.
- web/src/lib/types/index.ts mirrors content_outcome.
- CLAUDE.md documents it beside undeclared_fields / dropped_fields.

The outcome string is now one exported models constant rather than a
literal in two packages: the server writes it and the CLI compares it, and
a rename on either side would have left the warning silently never firing
— indistinguishable from the bug being fixed.

No duration is claimed on any surface. It is not established that the
flush always lands (BUG-3000), so a number would be a claim nothing here
can support; a test asserts the warning carries none.

* docs: stop the pending-flush prose claiming more than the code does (codex round 3)

Three overclaims, in the change whose whole point is not overclaiming.

- Every surface said the stored copy 'catches up afterwards'. Nothing
  establishes that: the flush belongs to a browser tab, and whether it
  ever happens is the open half filed as BUG-3000. Now 'updated only when
  that tab next flushes, and nothing guarantees it does'.
- 'a get/list answers with the PREVIOUS content' — a default list is
  summary-shaped and carries no content at all, so only get, or list with
  full=true, can answer with anything.
- 'the stored form is NOT byte-identical' — the round-trip guard's own
  fixtures include three constructs that survive unchanged, so the
  measurement contradicted the sentence. Now 'may not be', with the
  stable case named.

Two doc comments the last two commits falsified: models.Warnings
described only undeclared keys, and the TypeScript mirror said TASK-2878
'added two more members'. Both now name the KINDS rather than counting
members, so the next addition does not falsify them again.

Also records the version decision where the next person will look: a
POST-0.30 NO BUMP entry in version.go and CLAUDE.md. v0.9 bumped because
list rows LOST fields consumers read; here nothing is removed or retyped
and the one field whose value moved was answering a content PATCH with
content from before that PATCH, so there is no reliance to break — the
BUG-2304 disposition rather than v0.9's.

* docs: the same overclaim, swept as a class this time (codex round 4)

Round 3 fixed the user-facing prose and left the identical claim in the
code comments — the named instances rather than the class (CONVE-18).
Swept the branch diff for both shapes instead of patching the four sites
the review listed:

- 'is not byte-identical' as an invariant: models.ItemWriteWarnings,
  the handler comment, the Go test comment and the round-trip guard's
  comment now say the stored form MAY differ, and name the case that
  survives unchanged. The two remaining hits are correctly quantified —
  CLAUDE.md's 'may also differ', and a test asserting at least ONE
  construct differs, which is existential and true.
- 'the row is written when a tab next flushes': now 'only if and when',
  with the BUG-3000 pointer, in the constant's own doc comment where a
  reader meets it first.

The NO BUMP entry claimed 'there is no reliance to break', which is not
establishable. A consumer could have detected the applier path by
noticing the response echoed something other than what it sent, and that
stops working. Restated as no DOCUMENTED OR SUPPORTED reliance, with the
counter-case named: it was never documented, a genuinely lost write
produced the same mismatch, and warnings.content_outcome answers that
question properly in the same change.

Also asserts what round 4 asked about: content plus an undeclared field
in one applier-path request must produce ONE warnings object carrying
both members. Mutation-checked — a marker that replaces the object
rather than building on it now fails.

* docs: the flush is a collab-snapshot write, not a promise from one tab (codex round 5)

Every surface said the row is updated when 'that tab/editor next
flushes', which over-specifies the mechanism in a way an agent could act
on. The row is updated by any later ?source=collab-snapshot write: in
practice that is the tab which applied the content, but the row does not
care which caller sends it, and an open tab may be non-electable, so
'that tab' is neither necessary nor sufficient.

Swept as a class again rather than per site — CLI help, CLI stderr line,
MCP catalog, instructions.md, CLAUDE.md, the TypeScript mirror, the
models doc comment and the constant's own comment. Verified by re-running
the grep that found them: no single-tab attribution remains.

The review also cited README as carrying this text; it does not — the
grep returns nothing there, and nothing in this branch touched it.

* fix(test): the applier probe loop was flaky, and the marker is write-scoped (codex round 6)

Two findings, and the first one was real in a way I had not seen.

The review said the focused tests fail on a 429 from the shared
waitForApplierPath probe loop. My own runs were green, so I ran it the
way that would show it instead of assuming: -count=5 across both BUG-2995
tests fails with exactly that 429. One test at -count=1 stays inside the
API limiter's burst; two at -count=5 do not. The helper now waits out the
limiter rather than failing on it, and keeps the diagnoses apart — being
rate-limited throughout is inconclusive about the route, not evidence the
server wrote items.content directly.

That exposed a second flake underneath: with the probe slowed, election
lost the race in 2 runs of 5 against a 3s deadline. That deadline is a
READINESS wait, not an assertion — every millisecond is spent only while
the room is not ready — so a too-short one invents a flaky test rather
than catching a product bug. Raised to 15s, which the passing runs never
approach (they finish in about a second). Stable over four -count=5 runs,
including alongside the BUG-2840 test that shares the helper.

The second finding: the marker could be stale, since a concurrent
collab-snapshot flush can update the row between the apply and the
response. Rather than synchronise, every surface now says what the value
actually means — it describes THIS WRITE, that the content went to the
document rather than the row, and is not a live reading of the row. The
caller's action is unchanged either way: re-read rather than re-send.

* docs: the CLI line said the stored copy has not been updated (codex round 7)

Last surface still phrased as a live reading of the row. A concurrent
collab-snapshot flush may already have updated it, so the honest form is
what this request did: it applied the content to the document and did not
write the stored copy itself.

Also sharpens the probe loop's rate-limited failure message. The flag is
sticky, so some probes did complete — the branch does not suppress a real
failure, it declines to name a cause the run did not measure.

* fix(test): the applier readiness helper stops paying the rate limiter (BUG-2995, CI)

CI's Go job failed on TestApplierPathRefusalLeavesNoOpLogRow_OpenChildren
— a test green on main, untouched by this branch, and a THIRD caller of
waitForApplierPath I had not accounted for. It spent its whole budget
being answered 429.

The helper inferred the content route by PATCHing every few milliseconds
and watching whether items.content changed, with a comment saying no
exported accessor for electable conns existed. That was true when it was
written and stopped being true at TASK-2987, which added
RoomManager.HasElectableApplier so the handler could learn the route
without taking it. The helper now asks that predicate directly.

Leaving it as a write loop was not just wasteful: those writes go through
the ordinary API limiter (600/min, burst 60) and the bucket is shared
across the package, so every caller drains it for the tests that follow.
One caller stayed under the burst. Adding a second pushed a third,
unrelated test over the edge — a helper that consumes a shared
exhaustible resource makes other tests fail as a function of how many
ran first, which is the worst kind of flake to read from a log.

Asking the predicate costs nothing, cannot be rate-limited, and answers
the exact question rather than inferring it from a side effect. The three
callers now run in about a second at -count=3 where the probe loop took
multiple seconds and could fail outright.
2026-09-10 12:37:49 -04:00
..