Files
pad/cmd
xarmian 80e32b4e0a fix(items): eleven hand-built doors carry the stale-body marker (BUG-3033) (#1344)
* docs: make test-pg is not safe against itself in one worktree (BUG-3033)

CLAUDE.md said the target is safe across worktrees, which is true and
incomplete. TEST_PG_PROJECT is basename + cksum of the absolute path, so
two overlapping runs in ONE worktree share a compose project: the second
`up -d --wait` attaches to the first run's container, and whichever run
finishes first executes the unconditional `down -v` and destroys the
other's database mid-test.

Measured on the BUG-3000 trail, where the survivor's connection-refused
failures were first read as evidence about the code. The note also records
that a PASSING run carries ~41 redis connection-refused lines, so that
noise is not the diagnostic — the failure text is.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): six hand-built doors carry the stale-body marker (BUG-3033)

BUG-3000 put content_state on models.Item, so every door that serialises
the struct inherits it. These build their own shape and inherit nothing:

  - PlaybookRunResponse.Body (handlers_playbooks.go)
  - AgentBootstrapConvention.Content (handlers_bootstrap.go)
  - BootstrapIncludeItem.Content, bodies mode (handlers_bootstrap.go)
  - the artifact export's markdown+YAML (handlers_artifact_export.go)
  - the MCP item resource's composed markdown (mcp/resources.go)
  - `pad playbook show` / `pad playbook run`, which decode narrow structs
    of their own and print the body

The last two are in the unit because an agent reads what the CLI prints,
not the wire: a marker the server sets and the CLI drops is a fix its
reader never sees — the same gap this bug describes for the MCP resource,
whose fetcher reads stdout and cannot surface a successful command's
stderr.

BootstrapIncludeItem is a fifth server door the bug does not name, found
by this unit's sweep and folded in under CONVE-18 rather than filed: same
file, same handler, same shape as the conventions door beside it.

The artifact export is a portable format, so the marker goes into the
existing provenance block as an additive omitempty key with NO
format_version bump. Decode uses a plain yaml.Unmarshal with no
KnownFields, so an older binary ignores the key; format_version is
compared with exact equality, so a bump would reject artifacts in both
directions for an added optional key. A current item's bytes are
unchanged. It signals rather than refuses; BUG-3032 owns the
signal/refuse/accept decision for the bundle format, and an additive key
forecloses neither of the other two.

Every marker is emitted only where a real body is — the includes site
sets it inside the same wantBodies guard that sets Content — so a
metadata-only projection cannot claim its absent body is stale.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* test(items): both-directions coverage for the six BUG-3033 doors

One test per door, both directions on one item, ABSENCE ASSERTED FIRST.
The order is the instrument: a marker stamped unconditionally satisfies
every positive assertion in these files, so a presence leg proves
something only after the same door has been shown to stay silent about a
current row.

Assertions are on the raw wire form — a decoded map, the exported bytes,
the rendered markdown — rather than on a Go struct, because a struct
round-trip passes even when the field is never serialised. That is
exactly the failure this bug is: a value computed correctly in the store
and dropped by a projection on the way out.

Four legs beyond the plain both-directions pair:

  - PlaybookShowResponse inherits the marker through an embed nobody
    tested. Pinned, because a refactor to an explicit projection — the
    shape BootstrapCollection and BootstrapRole already took for size —
    would drop it silently.
  - A metadata-mode include must carry NO marker even when its item is
    stale, since it serves no body; the premise (that the group really
    withheld the body) is asserted before the conclusion.
  - A current artifact must not merely lack the value but lack the KEY,
    and a marked one must differ by exactly one line and still decode
    with body and fields intact. Those are the compatibility claims made
    testable rather than asserted in a comment.
  - An UNRECOGNISED content_state must not render as this unit's
    specific claim about a live editor. The vocabulary is shared with
    BUG-2995's content_outcome and is expected to grow.

The CLI and MCP legs drive the command and the resource rather than the
helper and the formatter (CONVE-19): a direct call vouches for the
function while leaving the narrow decode struct — where the defect
actually lives — free to omit the key.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* docs: the read-side content_state marker (BUG-3033, CONVE-23 sweep)

Three consumer-facing artifacts described the write-side warning and
said nothing about its read-side counterpart, so an agent reading them
would still conclude a stale read is silent. That was already true
before this change — BUG-3000 shipped the marker without documenting it
outside the field's own doc comment — and adding six more doors is what
makes it worth closing rather than inheriting.

  - CLAUDE.md gains a read-responses bullet beside the three write
    warnings: what the value means, that it is emitted only where a real
    body is, that the window is UNBOUNDED because nothing server-side
    flushes, and that the correct response is re-read later, never
    re-send.
  - internal/mcp/instructions.md had a sentence saying a get before the
    flush "answers with the PREVIOUS content" — true and now incomplete,
    since the get says so itself. It also gains the case an agent cannot
    infer from its own write: someone else's tab holds the edits and you
    wrote nothing. pad_playbook's entry says a marked body may be
    superseded, and that this is a reason to re-read, not to refuse to
    run.
  - internal/mcp/version.go gains a POST-0.31 NO BUMP entry. The
    disposition is BUG-2304's, argued at length in the BUG-2995 entry
    above it: nothing is dropped or retyped, and a current item renders
    exactly as before. The item resource is the half worth naming — a
    text resource has no additive key, so the marker is a line of
    markdown, and a consumer diffing that text sees a new block when and
    only when the body it is about is stale.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* docs: contract comments name the marker they now emit (BUG-3033)

formatItemAsMarkdown's doc listed the blocks it composes and this change
added one; the artifact's compatibility claim now cites the instrument
that proves it rather than asserting it (testdata/*.golden.md pin the
encoded bytes exactly and were not touched to land the key).

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(cli): pad item edit warns before seeding $EDITOR from a stale body (BUG-3033)

A seventh door, found by re-sweeping the population, and the only one
whose consequence is not a confusing read. `pad item edit` is a
read-modify-write over the WHOLE body: it seeds the editor from the row
and PATCHes the entire edited text back with no concurrency token. When
the row is behind the live collaborative document, the editor is seeded
with content that predates edits which exist and are durable in the
op-log, and the save replaces them with a version derived from a state
before them — visible to neither the person editing nor the person whose
tab holds them.

Unlike an ordinary lost update this needs only ONE writer plus an open
tab; the other side is the unflushed state of a document nobody is
typing in.

The warning is printed BEFORE the editor launches, which is the only
point at which it can still change what the user does — afterwards it
would be read beside an "Updated TASK-5" line. Its wording differs from
its two siblings for the reason a third one exists at all: the
consequence is that saving destroys something, not that the text is old.

It warns and proceeds. Whether `edit` should refuse, prompt, or send a
DIFF instead of the whole body is a behaviour decision with its own item
(BUG-3035, which also asks whether `item update --stdin` joins any gate
`edit` grows), not something to settle inside a sweep for missing
markers.

Two tests, and the second is the load-bearing one: the gating table
vouches for the helper, and the end-to-end leg vouches for the BINDING —
a fake editor announces itself on its own stderr, which OpenInEditor
wires to os.Stderr, so the ordering claim is an assertion about one
stream rather than about presence. Both mutants (drop the call; move it
after the editor) are killed by that leg alone.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): playbook summaries carry the marker; two tests stop passing wrongly

Codex round 1, three findings, all valid.

P2 — an eighth door, and this unit's population table missed it with the
precedent already in the tree. A playbook `summary` is the body's first
paragraph truncated, so a stale body makes a stale summary, and an agent
ROUTES on that description. BUG-3000 put the marker on
cli.ItemSummary.ContentPreview on exactly this reasoning ("a
content_preview is content"); calling the same bytes a summary does not
change where they came from. Carried on AgentBootstrapPlaybookMeta, which
both the bootstrap blob and the /playbooks endpoint render, and through
both CLI renderers — one stderr line naming the stale refs rather than a
per-row marker, since a per-row marker on a long list is noise and a bare
count sends the reader back to diff it by hand.

Set only when a summary was actually emitted: no summary, no claim. It
also does not mark the other metadata, which comes from the fields blob
that the collaborative document does not hold.

P3 — the edit tests accepted an always-on warning. They supplied only a
stale response, so a call site forced to warn unconditionally passed
them, and the helper's gating table cannot see a wiring error. Now both
directions at the COMMAND boundary, with two premises asserted before any
conclusion: the fake editor ran, and the edited body was really PATCHed.

P3 — the artifact test accepted a corrupted body. It compared line counts
and asserted only that the decoded body was non-empty, so an export that
replaced the body with unrelated text passed. Now the EXACT body on both
exports, plus byte equality after removing the marker line and
normalising exported_at — with a check that the normalisation removed
something, so the comparison cannot pass by stripping both sides.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): backlink + search snippets carry the marker; add the class guard

Codex round 2: two more doors and two test gaps, all valid.

P2 — backlink snippets. models.Backlink.Snippet is a window cut from
the SOURCE item's body, so a stale body makes a stale snippet. This unit's
sweep could not see it: its vantage point was internal/server,
internal/mcp and internal/cli, and this shape is built in the STORE. The
predicate needed a non-default table alias (the backlink queries call
items `s`), so contentStateSQL became contentStateSQLFor(alias) with the
old constant as its "i" case — a function rather than a second copy,
since two hand-maintained spellings of this predicate would be two
chances to drift. The marker describes the SOURCE of each link, never the
target the caller asked about, and a test asserts marking the target
changes nothing.

P2 — `pad item search` and `pad item backlinks` printed body-derived
snippets and dropped the marker the wire already carried. Both now use
one shared renderer with the playbook summaries, so the surfaces cannot
word the same fact three ways.

P3 — the edit test asserted only that a PATCH occurred, though it claimed
to exercise saving; it now asserts the exact content that reached the
server. P3 — the summary test had one stale row, which cannot tell a
correct renderer from one naming every ref or only the first; it now
interleaves stale and current entries, asserts the complete set and
EXACTLY ONE warning line, and covers the bootstrap renderer that had no
test at all.

CONVE-35 — three rounds each found another member of this class, which
means the reviews were measuring my enumeration rather than the code. So
the table is now a test: every site turning an item body into text under
another name is listed with its ruling, keyed on the exact call LINES
with multiplicity, and anything else fails closed.

Its first draft keyed on file+helper and a negative control caught the
hole: a SECOND call site in an already-listed file passed silently — and
handlers_bootstrap.go, which is listed, is exactly where a new projection
would be written. Four controls now run: a new compiling call site goes
red, a broken walk root goes red, a removed site goes red, and comments
mentioning a helper stay green. The guard states what it cannot see — a
door built on a helper it does not know — and its failure message asks
for the helper, not just the site.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): title-only search results drop the body marker; guard parses Go

Codex round 3, six findings, all valid.

P2, and a defect this unit INTRODUCED: Store.Search's direct-ref and
numeric paths set Snippet = Item.Title and clear the body, but kept the
BODY's content_state — so the new CLI renderer announced that a TITLE was
derived from a stale body. Cleared at the source rather than patched in
the renderer, which restores models.Item.ContentState's documented
invariant and fixes every other consumer of those results at the same
time. The FTS path beside it keeps the marker deliberately: there the
snippet IS cut from the body, so the marker still qualifies text the
result serves. A test asserts both directions, because "clear it
everywhere" would delete the signal this unit exists for.

P2 — the enumeration guard was itself fail-open. It passed a one-line
function calling a helper, a helper assigned to a local and called
through it, a comment inside the call expression, and an argument changed
below the opening line of a multiline call, while FAILING on prose and
strings that merely mentioned a helper. Wrong in both directions is the
signature of a scanner not reading the language it claims to read, so it
now parses Go: call expressions matched on the AST, where comments and
line breaks are not facts. A helper NAME used as a value is reported too
and fails closed, since the call through an alias is invisible. All four
of codex's bypasses now go red and both false-positive cases stay green.

P2 — FTSSnippet was missing from the helper list entirely, so four
existing derivation sites were outside the table. Added and ruled; the
stated boundary was understating itself and now says so.

P2 — browser renderers (BacklinksPanel, CommandPalette) drop the marker.
The wire and the TypeScript type now carry it; where a stale badge
belongs in a panel or a palette is a UI decision, filed as BUG-3050 on
the same grounds BUG-3033 was split from BUG-3000.

P3 — cross-workspace backlinks had no coverage: clearing ContentState on
every result of that query left the same-workspace test and the existing
cross-workspace tests all green. P3 — the snippet tests had ONE stale row
while their comment claimed interleaving, the same gap fixed for
summaries one test earlier; a renderer reporting only the first stale
source passed them. Both now use multiple stale rows separated by current
ones, and that mutant dies on all four legs.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA

* fix(items): close the guard's helper-named bypass; seven tests stop passing wrongly

Codex round 4, enumeration-prompted. No new Go door — its census of 434
non-test files found the two remaining Go omissions are already filed
(workspace export on BUG-3032, cross-workspace copy attached to it) and
the browser set belongs to BUG-3050. Everything below is in-class.

The guard had a real bypass. A declaration whose own NAME was a helper
got a special traversal that checked CallExprs and returned false, so the
value-use arm never ran inside it: a compiling, callable
`func (p ReviewProjection) PlaybookSummary() string { render :=
contentPreview; return render(p.Content) }` served unmarked stale prose
while the guard passed, and renaming the method made it fail. The arm was
redundant as well as wrong — notAValueUse already marks every
FuncDecl.Name — so it is gone. It also skipped a `build` directory it
never disclosed, which hid a real file at internal/server/build/.

Three doc claims it made were false and are corrected rather than
softened: "anywhere under internal/ or cmd/" (it skips four directories,
now named), "helper values fail closed" (they did not), and that the
browser surface is a pure rendering choice (the share paths do field
copies that discard the marker before any rendering decision). It now
states plainly that its claim is narrow — no UNRULED USE of four helpers
— and must not be cited as proof that every door carries the marker.

Seven mutations survived the tests, all the same species: they asserted
the MARKER without asserting the BODY it qualifies. Replacing a served
body with "WRONG BODY", dropping it entirely, or suppressing stale
summaries and snippets while keeping the warnings all passed. Every door
test now pins the exact text, and the backlink premise finally sees the
snippet it is about — cli.Dim writes to color.Output, so an os.Stdout
swap missed it and an earlier draft recorded that as a limit and asserted
row headings instead, which is precisely what let that mutation live.

Also: the numeric direct-ref path had no coverage (the test queried a
prefixed ref only), backlink source-correlation passed when replaced with
"any op-log row exists anywhere", and the FTS leg never asserted a
snippet was produced. Search's surviving-marker comment claimed it always
qualifies served text; an FTS title match on an empty-bodied item is the
counter-example, and the comment now says so.

Claude-Session: https://claude.ai/code/session_01PDYjP67KTAcqpFpo3ijAAA
2026-09-13 18:34:16 -04:00
..