Commit Graph

755 Commits

Author SHA1 Message Date
xarmian 100da86188 feat(nav): surface the repo link in the sidebar footer
A running instance had no visible connection to the project it is, so
people went to a search engine. The reporter called it "purely a QOL
addition"; what makes it worth a commit is that the link already EXISTED
and was simply unreachable — buried in the user-menu dropdown under
Resources. This is a discoverability fix, not a new capability.

Icon-only, in `.footer-row` beside the collapse / theme / bell controls,
because that row is already where this instance's chrome lives. The mark
carries no accessible name of its own, so the anchor has an `aria-label`
naming both the destination and the new tab, and the SVG is
`aria-hidden` so it is not announced twice.

`$lib/brand/links.ts` exists because the URL was already written twice
in `UserMenuResources` and this would have been the third copy. A URL
duplicated across every surface that shows it is a rename that goes
half-applied. The module holds addresses only — list ORDER stays with
the surface that renders a list, since the two lists are deliberately
different lengths and the ordering contract lives in docs/brand.md.

The tests assert the binding, and the last one asserts the property the
others cannot: comparing a rendered href to the imported constant passes
identically for a component that hardcoded the same string, because both
sides end up as the same characters. That is a question about SOURCE, so
a narrow guard reads the two consumers and asserts the literal is absent
while `GITHUB_REPO_URL` is present — so the absence means "imported"
rather than "the link was deleted". Named files rather than a glob, so
it cannot fail for an unrelated file nor quietly stop covering these two.

Closes #1168
2026-09-01 03:44:02 +00:00
xarmian 7f640a9c40 feat(attachments): render markdown and plain-text attachments in the viewer
The arm itself, plus the render seam and the browser proof.

`renderMarkdownDocument` is a third thin wrapper in FRONT of the shared
`marked` pipeline, following `renderMarkedWithAttachments`'s precedent
rather than standing up a second renderer. It omits two things
deliberately. No wiki-link resolution: an attached `.md` was authored
elsewhere, so resolving its `[[brackets]]` against whatever workspace is
showing it would silently retarget a foreign document's links at local
items — BUG-2830's hazard entered through the front door. No attachment
context, and it CLEARS the module-level context for the duration rather
than merely leaving it alone: a nested call from a resolver or a
`missing` hook that itself renders markdown would otherwise inherit the
outer document's workspace and resolver. Save, clear, restore, mirroring
the sibling wrapper. Sanitization is inherited, not re-derived, so an
attached document is governed by the same allowlist as item content.

Plain text does NOT go through the pipeline. A `.txt` renders in a
`<pre>` as text, because interpreting a plain-text file's asterisks as
formatting would misrepresent its content; Svelte escapes it, so that
path emits no HTML at all.

THE FALLBACK ARM'S CONDITION CHANGED, and this is the part to check
hardest. It read `shownRenderer !== 'raster-image'`, which was correct
while the union had one member and would have drawn "No preview
available" over every text document the moment it had two. It is now
`=== null` — the registry's actual "no renderer claims this" answer —
which says what it means and stays correct when 'pdf' lands.

INTERACTION, all of it found by review or re-reading rather than by the
unit suite:

- The wheel handler consumed every wheel before its exclusions, so the
  card could never scroll. The text exclusion returns BEFORE
  `preventDefault`, the opposite of every other exclusion there: the
  others want the wheel swallowed, this one wants it delivered. Scroll
  chaining to the inert page is stopped by `overscroll-behavior:
  contain`, so the guarantee the `preventDefault` provided is kept.
- The full-bleed layer took `pointer-events: auto`, so a click on the
  empty area targeted it and backdrop-close was broken for this arm
  alone. The layer is inert; the CARD is the interactive surface.
- `touch-action` INTERSECTS down the ancestor chain, so the card's
  `pan-y` could never override the stage's `none` and a phone could not
  scroll at all. The stage gives up its pan claim on this arm only.
- The card had no tab stop. The viewer's arrow keys are its own
  next/previous navigation, so a keyboard-only user could open a
  document and never reach past its first screen. `tabindex="0"` plus
  `role="document"` and the filename label; the linter warning is
  suppressed narrowly with its reason at the site.
- The focus-handoff effect tracked `loader.phase` — the IMAGE loader,
  which this arm disposes, so it never changes there. This is the only
  arm whose CONTENT is focusable, so a reload unmounted a focused link
  and stranded focus outside the modal.
- `resolvedSize` and `revalidateToken` ride the load key SCOPED to the
  text arm. The key is shared, so an unconditional append re-ran the
  effect for the raster arm too and restarted image loads. The token is
  there because a parent RESTORE drives the image loader through the
  metadata probe's answer but cannot see a failed text GET's `error`
  phase — without it, a preview that 404'd while archived stayed
  permanently errored after the restore that fixed it.

E2E, because three of these guarantees are CSS mechanisms and the jsdom
suite injects no component styles — `getComputedStyle` there returns the
engine default for every element, so two assertions written for them
could not fail and were deleted rather than banked as coverage.
`web/e2e/attachment-text-preview.spec.ts` covers the render, backdrop
close, scrolling with no leak to the surface behind, and selection. Its
FIRST RUN is what caught the feature rendering raw source, which the
whole green unit suite could not see.

CONVE-23 sweep on the prose this falsified: the ADMISSION vs THE ARM
block enumerated 'raster-image loads bytes / null is no-bytes' as a
two-way split, and the fallback arm described itself as "an entry the
viewer cannot draw as an image". Both rewritten, plus a paragraph on why
two byte-loading arms leave the no-bytes invariant unchanged in kind.

`.pad-e2e-*/` is gitignored: a shared checkout runs concurrent suites, so
each seat points `PAD_E2E_DATA_DIR` at its own, and those hold a
generated encryption key and a multi-MB WAL.

`item-attachment-strip.spec.ts` changes here because it is a CONSEQUENCE
of this arm, not a separate concern. Two of its tests uploaded a
`text/plain` file and asserted the viewer showed "No preview available"
over it — true when written, false by design once text previews. CI
caught them; my sweep had not, because I swept the unit tests and stated
that boundary nowhere, which reads identically to a complete sweep
(CONVE-18's amended half). The fix keeps each test's SUBJECT — both are
producer→host wiring tests whose named subject is the fallback arm — and
moves the vehicle to PDF, which keeps every property the fixture was
chosen for while remaining unclaimed by any renderer. It carries a note
saying it will go red again when PLAN-2393 builds the `'pdf'` slot, and
that the red is the design: pick the next unclaimed type, never weaken
the assertion.

Closes #1169
2026-09-01 03:44:02 +00:00
xarmian eca31efa9e feat(attachments): add the viewer text loader with two independent size gates
The markdown/plain-text counterpart to `viewerImageLoader`. The image
loader hands a URL to an `<img>` and lets the browser fetch, decode,
cache and cancel; text has no such element, so this module owns all
three of the things that come for free there:

- cancellation: no `src` reassignment drops the previous request, so
  every load carries an `AbortController` and repointing aborts it
- staleness: a late `await` can resolve after the user has navigated, so
  every completion is checked against the issuing token AND the active
  id (the no-`{#key}` switch-safety class)
- the size bound: the browser will stream a 20 MB log renamed `.md` into
  memory quite happily

TWO SIZE GATES, neither subsuming the other. The metadata gate refuses
before any request and saves the transfer. The response gate bounds the
bytes actually read, and exists because `LightboxImage.size_bytes` is
`number | null` BY DECLARATION — an emitter knows only what its own
surface gave it — so the metadata gate passes vacuously for any entry
that arrived without a size. Deleting either leaves a real hole, and the
tests name which hole each one covers.

The gates are restated at the request CHOKEPOINT rather than only at the
renderer, following the image loader's stated reason: the renderer
showing nothing is not the same as the loader asking for nothing. Tests
assert 'fetch was never called', not 'nothing was displayed'.

Fetching a type the server will not inline is not the risk; markdown is
served `Content-Disposition: attachment` and `fetch()` is unimpeded by
that. What keeps it safe is that the bytes never become active
same-origin content — PLAN-2393 DR-6 is honoured by the allowlist, which
admits neither HTML nor JavaScript, not by the disposition header.

Response-derived overflow reports NO figure. In that branch the declared
size is absent or demonstrably wrong, and echoing it produces "This file
is 10 B — too large to preview" — a sentence that reads as a viewer bug
rather than a file problem. Only the metadata branch, which has a
trustworthy number, reports one.

The bound is measured in BYTES on both the streaming and the
`response.text()` leg, and the streaming leg decodes with
`{ stream: true }` so a chunk boundary splitting a multi-byte character
cannot corrupt the text. The module is honest about where the fallback
is weaker: it bounds what is RENDERED everywhere, but what is HELD only
on the streaming path, whose fallback audience is jsdom.

20 tests. The stream fixture's `text()` THROWS, so an implementation
that ignored the stream and buffered everything cannot pass the tests
written to forbid that; the fixture reports read count and cancellation
so a test can assert the read STOPPED, since draining-then-measuring
reaches the same `too-large` and only the read count tells them apart.
`retry` is inert outside `error` — for `too-large` the guard is dead
code, but while LOADING it is the only thing preventing two concurrent
requests for one entry, a case a surviving mutant exposed.

Refs IDEA-2712
2026-09-01 03:44:02 +00:00
xarmian cd2bf7f977 feat(attachments): claim markdown and plain text for an in-app text renderer
Adds the third preview predicate GitHub #1169 needs, and widens the
surface-renderer union onto the 'text' slot PLAN-2393 reserved.

`canPreviewAsText` is deliberately NOT an edit to either existing
predicate. `canOpenInViewer` (DR-16) asks what the in-app IMAGE viewer
may decode; `canBrowserPreview` (DR-5) asks what the BROWSER may be
handed in a new tab, where for `text/markdown` the honest answer stays
no because the browser downloads it. Widening DR-5 to reach #1169 would
route markdown to the Open-in-new-tab action and reproduce the exact
download-instead-of-render behaviour the issue reports. The new question
— do WE fetch the bytes and render them ourselves — is the only one
#1169 asks, and it gets its own predicate. `canBrowserPreview` and its
tests are untouched, which is the signal that nothing widened.

MIME-exact, never by category: the server's `CategoryText` CONTAINS the
force-download bucket (`text/html`, `text/javascript`,
`application/javascript` — `internal/attachments/mime.go`), so a
category test would admit exactly the types PLAN-2393 DR-6 forbids
inlining. An allowlist excludes them by construction.

Deliberately outside the server mirror: `inlineSafe` in mime.go declares
itself the mirror of `VIEWER_MIMES` + `BROWSER_PREVIEW_MIMES`, the set
the server may send `Content-Disposition: inline`. This set must never
join it — we never ask the browser to inline these bytes, we `fetch()`
them (which a download disposition does not impede) and render sanitized
HTML ourselves. That is what lets `text/markdown` preview in-app while
still being served as an attachment.

`isMarkdownAttachment` consults the FILENAME, and that is the path that
actually fires rather than belt-and-braces: an uploaded `.md` is stored
as `text/plain`. `ValidateUpload` sniffs the bytes with
`http.DetectContentType`, which answers `text/plain` for prose, and
returns the SNIFFED entry; the extension is used only to REJECT a
mismatch, and `.md` → `text/markdown` shares `CategoryText`, so nothing
rejects. Measured: `ValidateUpload([]byte("# Heading\n..."),
"preview.md")` returns `mime="text/plain"`. The MIME check stays first
(an explicitly-typed row is honoured whatever it is named); the
extension fallback is GATED on the MIME already being in the set, so a
filename can never widen what previews. Server side filed as BUG-2841.

Set is smaller than what we could render: csv/tsv/json/xml/yaml/toml are
allowlisted uploads and all left out, because each has an obviously
better rendering this unit does not build, and shipping them raw now
would make that rendering a regression later. Tests pin the exclusions
with that reason.

`TEXT_PREVIEW_MAX_BYTES` carries its receipt: measured over 25 repo
`*.md` files (p50 3.6 KB, max 82 KB) and 63 workspace doc bodies (p50
5.0 KB, max 23 KB); 1 MiB is ~12x the largest observed, sits under the
25 MiB upload bound so it has a live range, and errs toward rendering.
The comment states the limit honestly — the cap always bounds what is
RENDERED, but it saves the TRANSFER only when the size is known before
the load.

One existing assertion changed meaning rather than breaking: the
renderer test asserted `text/plain` has no renderer. It now asserts
'text'. That is the contract change, made explicitly and annotated. Two
force-download assertions are labelled as regression guards rather than
evidence — they pass against origin/main too.

Refs IDEA-2712
2026-09-01 03:44:02 +00:00
xarmian b6afbb8fca fix(links,web): align the JS wiki-link grammar with Go's . (BUG-2834)
The Go and JS wiki-link patterns were byte-identical source text and did
not mean the same thing. Both spelled the escape alternative `\\.`, but
Go's RE2 `.` excludes only LF while ECMAScript's also excludes CR, U+2028
and U+2029. A body with a backslash immediately before one of those three
was INDEXED by the server and NOT RENDERED by the client: the backlink
panel claimed a link the document refused to draw. CRLF line endings make
the CR case the plausible one.

Measured on both sides before deciding anything, over nine code points.
Exactly three diverge; VT, FF and U+0085 agree, which bounds the
divergence at precisely ECMAScript's LineTerminator set minus LF rather
than leaving it open at "some whitespace controls".

Aligns JS UP to Go (`\\[^\n]`) rather than narrowing Go, for three
independent reasons: the grammar's other alternative already admits RAW
CR/LS/PS in both languages, so narrowing Go would make `[[A<CR>B]]` legal
and `[[A\<CR>B]]` illegal; narrowing Go would stop ExtractWikiLinks
returning rows it currently returns, and the next reconcile would DELETE
them, which is BUG-2805's damage shape; and markdown.ts's own
splitWikiBody already treats `\<CR>` as an escape pair, so only its regex
disagreed. LF stays excluded on both sides — scanBracketBody depends on
that and is untouched.

Also collapses the two duplicate regex literals in markdown.ts into one
exported WIKI_LINK_PATTERN_SOURCE. Exported as source text, not a RegExp
object, because a `/g` regex carries lastIndex and sharing one between a
replace() and a test's exec() would couple them through it.

The harness is the part meant to outlive the fix.
testdata/wiki_grammar_corpus.json is read by BOTH languages, and carries
expectations derived from the grammar spec rather than from either
implementation — comparing the two implementations to each other would
have reproduced the exact blind spot that hid this, since looking
identical is what they already did. Pure ASCII with every control
character as a \uXXXX escape: an early probe typed U+2028/U+2029 into a
shell heredoc, silently lost them, and would have "confirmed" the bug on
two cases that were actually spaces.

JS assertions are split across the node and jsdom projects because
renderMarkdown finishes through DOMPurify and returns '' without a DOM,
where it would fail for a reason unrelated to the grammar. Same split,
same reason, as the existing markdown.shareAttachments pair; the jsdom
file carries a leg proving the DOM path is live, since '' satisfies every
not.toContain assertion.

Negative-controlled both ways: reverting the JS pattern fails exactly 9
assertions (3 corpus + 3 per call site) and nothing else; narrowing the
GO pattern to JS semantics fails the same 3 cases from the other side, so
both halves are live instruments rather than tests that cannot fail.
2026-08-31 23:23:56 +00:00
dependabot[bot] 459ba370ec chore(deps)(deps): bump the npm-minor-and-patch group (#1223)
Bumps the npm-minor-and-patch group in /web with 16 updates:

| Package | From | To |
| --- | --- | --- |
| [@tiptap/core](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/core) | `3.30.2` | `3.30.5` |
| [@tiptap/extension-bubble-menu](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-bubble-menu) | `3.30.2` | `3.30.5` |
| [@tiptap/extension-code-block-lowlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-code-block-lowlight) | `3.30.2` | `3.30.5` |
| [@tiptap/extension-collaboration](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration) | `3.30.2` | `3.30.5` |
| [@tiptap/extension-collaboration-caret](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration-caret) | `3.30.2` | `3.30.5` |
| [@tiptap/extension-link](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-link) | `3.30.2` | `3.30.5` |
| [@tiptap/extension-placeholder](https://github.com/ueberdosis/tiptap/tree/HEAD/packages-deprecated/extension-placeholder) | `3.30.2` | `3.30.5` |
| [@tiptap/extension-table](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-table) | `3.30.2` | `3.30.5` |
| [@tiptap/extension-task-item](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-item) | `3.30.2` | `3.30.5` |
| [@tiptap/extension-task-list](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-list) | `3.30.2` | `3.30.5` |
| [@tiptap/pm](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/pm) | `3.30.2` | `3.30.5` |
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) | `3.30.2` | `3.30.5` |
| [@tiptap/suggestion](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/suggestion) | `3.30.2` | `3.30.5` |
| [mermaid](https://github.com/mermaid-js/mermaid) | `11.17.0` | `11.17.2` |
| [svelte-dnd-action](https://github.com/isaacHagoel/svelte-dnd-action) | `0.9.78` | `0.9.79` |
| [marked](https://github.com/markedjs/marked) | `18.0.10` | `18.0.11` |


Updates `@tiptap/core` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.30.5/packages/core/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages/core)

Updates `@tiptap/extension-bubble-menu` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.30.5/packages/extension-bubble-menu/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages/extension-bubble-menu)

Updates `@tiptap/extension-code-block-lowlight` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.30.5/packages/extension-code-block-lowlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages/extension-code-block-lowlight)

Updates `@tiptap/extension-collaboration` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.30.5/packages/extension-collaboration/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages/extension-collaboration)

Updates `@tiptap/extension-collaboration-caret` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.30.5/packages/extension-collaboration-caret/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages/extension-collaboration-caret)

Updates `@tiptap/extension-link` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.30.5/packages/extension-link/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages/extension-link)

Updates `@tiptap/extension-placeholder` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.30.5/packages-deprecated/extension-placeholder/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages-deprecated/extension-placeholder)

Updates `@tiptap/extension-table` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.30.5/packages/extension-table/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages/extension-table)

Updates `@tiptap/extension-task-item` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages/extension-task-item)

Updates `@tiptap/extension-task-list` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages/extension-task-list)

Updates `@tiptap/pm` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.30.5/packages/pm/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages/pm)

Updates `@tiptap/starter-kit` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.30.5/packages/starter-kit/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages/starter-kit)

Updates `@tiptap/suggestion` from 3.30.2 to 3.30.5
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/v3.30.5/packages/suggestion/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.5/packages/suggestion)

Updates `mermaid` from 11.17.0 to 11.17.2
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.17.0...mermaid@11.17.2)

Updates `svelte-dnd-action` from 0.9.78 to 0.9.79
- [Changelog](https://github.com/isaacHagoel/svelte-dnd-action/blob/master/release-notes.md)
- [Commits](https://github.com/isaacHagoel/svelte-dnd-action/commits)

Updates `marked` from 18.0.10 to 18.0.11
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.10...v18.0.11)

---
updated-dependencies:
- dependency-name: "@tiptap/core"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-bubble-menu"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-code-block-lowlight"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration-caret"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-link"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-placeholder"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-table"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-item"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-list"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/pm"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/starter-kit"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/suggestion"
  dependency-version: 3.30.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: mermaid
  dependency-version: 11.17.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: svelte-dnd-action
  dependency-version: 0.9.79
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-31 17:15:38 -04:00
xarmian ba1255881d fix(server): refuse a decoded NUL in a JSON request body (BUG-2803) (#1220)
* fix(server): refuse a decoded NUL in a JSON request body (BUG-2803)

The body half of BUG-2782 (path) and BUG-2784 (query). A caller-supplied
string reached a Postgres text parameter, Postgres refused it, and the
handler answered 500 — the honest answer is 400.

WHY THE TRANSPORT RULE CANNOT BE EXTENDED, which is the whole reason this
is a different fix rather than a wider middleware. ValidateQuery works
because a decoded query value is a substring of the raw query with ASCII
substitutions: the bad byte in the raw text IS the bad byte in the value.
That property fails for a JSON body — the reachable NUL arrives as the
six-character escape, all ordinary ASCII — so no request middleware can
find it without decoding the body, which is the handler's job.

MECHANISM, each premise measured against encoding/json rather than
reasoned about:

  raw NUL inside a string  -> decode ERR (invalid character in string literal)
  raw NUL after the value  -> decode ERR
  the escape in a value    -> decodes to a string CONTAINING a NUL
  the escape in a KEY      -> same
  a DOUBLED backslash      -> decodes to literal text, NO NUL
  the uppercase spelling   -> not a JSON escape at all

So the escape is the only vector and its substring is a sound FAST PATH
(absent -> no NUL possible), but not a sufficient test: a doubled
backslash carries the same six characters and decodes to text. In this
product that is not hypothetical — items and documents store markdown,
and a document about JSON escapes is an ordinary thing to write. The
exact step is json.Decoder.Token(), which returns DECODED strings, covers
object keys and arbitrary nesting (an item's fields blob), and needs no
knowledge of the destination type.

NOT REFLECTION over the decoded value, the other obvious design: it sees
[]byte fields AFTER base64 decoding, so a body carrying legitimate binary
({"b":"AQAC"} -> bytes 01 00 02) would be refused for a NUL that is not
text. A token walk sees the base64 characters. No request struct has such
a field today (searched: []byte with a json tag in internal/server and
internal/models, non-test — only models.YjsUpdate.UpdateData, which no
handler decodes from a body); the token walk is chosen so adding one
later cannot silently start rejecting valid requests.

BUFFERING IS NOT A COST. json.Decoder.Decode already holds the whole
top-level value in memory — refill accumulates into dec.buf and grows it
by doubling (encoding/json/stream.go) — so streaming never avoided the
copy. Measured on the 64 MiB workspace-import shape, total allocation:
stream+Decode 354.7 MiB, ReadAll+Unmarshal 256.5 MiB, ReadAll+Decode
512.5 MiB. Peak heap is order-dependent and does not discriminate; the
first run of that measurement showed a 0.77x peak win that vanished when
the legs were swapped, so only the allocation figure is claimed.

POPULATION, measured on Postgres 17 through the real router with a
control leg on every endpoint (92 mutating routes enumerated via
chi.Walk; 13 probed):

  before: 12 of 13 DOOR (control 201 / NUL 500, SQLSTATE 22021)
  after:  0 of 13 — every NUL leg 400, every control leg unchanged

Confirmed doors: workspace name, collection name, item title, item
content, item fields value, item title via PATCH, comment body, agent
role name, view name, document title, webhook secret, workspace import.
workspace-token name is UNMEASURED, not clean — its control leg 500s on
an unrelated FK in this fixture. The other 79 routes are unprobed, not
claimed clean; the completeness argument is structural instead, and
enforced by a test rather than asserted.

SECOND DEFECT, named rather than slipped in: the six handlers that
decoded straight off r.Body had no http.MaxBytesReader either — the cap
decodeJSON has always applied — so each was an unbounded body read.
Routing them through decodeJSON closes that too.

COMPATIBILITY: json.Unmarshal refuses trailing non-whitespace after the
JSON value where Decode ignored it. Deliberate, same direction as this
fix, and the only behaviour change beyond the refusal. Trailing
whitespace still passes. An EMPTY body still returns a wrapped io.EOF,
because handlers_playbooks.go reads errors.Is(err, io.EOF) as "no
arguments supplied" — caught by TestPlaybookRunAcceptsEmptyBody, which is
exactly the wiring a helper-level change is blind to.

No call site changed for the refusal itself: all 65 decodeJSON callers
already turn a decode error into a 400 carrying err.Error().

Release note: a NUL character in a JSON request body now returns 400
instead of 500 on Postgres deployments.

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

* fix(server): follow the NUL refusal into JSON-encoded string fields (BUG-2803)

Codex round 1 on #1220: the check scanned ONE JSON layer, and several
fields cross the wire as JSON-ENCODED STRINGS rather than nested objects
— an item's fields, a collection's schema, a workspace's settings. The
OUTER decode of {"fields":"{...}"} yields the inner document as literal
text, in which the escape is still six ordinary characters and no NUL
exists, so the single-layer token walk passed it.

MEASURED on Postgres 17 with a control leg on each, after the
single-layer check was already in place:

  item.fields  as a JSON-encoded string   500   control 201
  collection.schema  as a string          500   control 201
  workspace.settings as a string          500   control 201

The error is DIFFERENT from the rest of this family, which is why it is
worth reading rather than assuming:

  insert collection: ERROR: unsupported Unicode escape sequence (SQLSTATE 22P05)

22P05, not the 22021 the path and query halves produce. The outer string
is pure ASCII so it never trips the text-encoding check; this is
Postgres's own JSON parser refusing the escape inside a document bound
for jsonb, which cannot represent a NUL. After this change all three
answer 400 with their control legs unchanged.

THE FIX: when a decoded string is itself a complete JSON object or array
— the class this API re-parses downstream — walk it too, to a depth
bound of 8. Recursion terminates on its own (each level is a strict
substring of the one above); the bound keeps a hostile body from buying
many full re-parses, and AT the bound the body is refused rather than
passed uninspected, since the escape is known to be present and the walk
has stopped looking.

WHAT THIS OVER-REFUSES, by design and pinned by a test: the rule is
structural, not destination-typed, so a plain TEXT field whose ENTIRE
value is a valid JSON document carrying the escape is refused too, even
though its column would have stored it. Prose ABOUT a JSON escape does
not parse as a bare document, so the case is narrow, and a value of that
shape breaks any consumer that parses it. The destination-typed
alternative — an allow-list of the fields that arrive JSON-encoded — is
exactly correct and goes stale in silence, which is the failure mode
ValidateQuery's comment rejects when it explains why per-site query
validators could not be written.

Tests: nested documents (fields/schema/settings/array/twice-encoded),
with controls for ordinary content, a doubled backslash INSIDE the
nested document, a string that starts like JSON but does not parse, and
prose that merely mentions the escape; the over-refusal pinned as a
decision rather than left as an accident; the depth bound; and a wiring
leg through the real router on SQLite, where the write would otherwise
SUCCEED so a green cannot be the database doing the work. Fixtures build
their JSON-encoded strings with encoding/json rather than hand-written
backslashes, since the escaping rules are the subject under test.

All four new tests fail with the recursion removed.

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

* test(server): build the NUL-bearing timeline fixture through the store (BUG-2803)

TestTimeline_NeverEmitsACursorItWouldRefuse built its fixture through the
API on a premise its own comment stated: "a structured id comes from the
item's fields blob, which nothing validates on write". BUG-2803 made that
false — decodeJSON now refuses a body whose strings decode to a NUL,
including one nested inside a JSON-encoded `fields` string — so the API
can no longer produce the row and the test 400'd on its fixture.

Repaired rather than deleted, because the DEFENCE it covers is still
live: rows in this shape can predate the rule, and the store has no such
check of its own, so a migration, an import or any future non-HTTP writer
can still produce one. The timeline must keep refusing to hand out a
cursor it would then reject.

The fixture now writes the blob directly, injecting the six-character
JSON escape rather than a raw NUL — the blob is JSON text and both
backends reject a raw NUL in it; the NUL comes into existence when Go
DECODES the blob, which is exactly how the timeline ends up with one
inside an entry id. The test is not vacuous under the change: it asserts
the NUL-bearing id took the positional fallback, so an injection that
failed to produce a NUL fails the test rather than passing quietly.

This is the CONVE-23 case — a change that falsifies existing prose owes a
sweep for that prose. The stale sentence was found by the test failing,
not by the sweep, which is the weaker of the two ways to find it.

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

* docs(server): correct the timeline comment BUG-2803 falsified (CONVE-23)

The entryID fallback's comment said note and decision ids "come from the
item's fields blob and nothing validates them on write". BUG-2803 made
that half false: the HTTP API now refuses a request body whose strings
decode to a NUL, including one nested inside a JSON-encoded `fields`
string. The sentence was true when written and nothing in this branch's
diff pointed at it.

The fallback still has to exist, and the corrected comment says why:
the STORE has no such check, so rows predating the rule — and anything
writing a blob by another path, a migration, an import, a future
non-HTTP writer — can still carry one.

SWEPT AND DELIBERATELY LEFT: two nearby comments
(handlers_timeline_id_collision_test.go, handlers_timeline_structured_test.go)
also say "nothing validates them on write". Both are about id FORMAT and
DUPLICATION — an imported artifact carrying a UUID-shaped id, a
hand-written blob repeating one — and this change validates neither. In
context those sentences remain true, so they are left alone rather than
edited into noise.

Sweep command: grep -rniE "nothing validates|not validated on write|no
validation on write|unvalidated" --include=*.go internal/ cmd/ — six
further hits, all about other subjects (github_pr raw writes, terminal
schema keys, push payload format, decodeJSON's size bound).

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

* fix(server): scope the nested-NUL walk to JSON-encoded fields (BUG-2803)

Codex round 2 on #1220. The nesting check from the previous commit
recursed into ANY string that parsed as a JSON document, on the argument
that a structural test beats a destination-typed one. That argument was
wrong in a way I had written down as an accepted trade and should have
weighed as a defect: a plain-text `content` value holding a JSON snippet
that merely MENTIONS the escape was accepted before this branch, is
stored in a text column that has no problem with it, and was newly
refused — including on RE-IMPORT of an export carrying it.

Refusing input the server itself produced is a worse failure than the
door the unscoped recursion was closing. Measured before the fix: a
workspace whose item content held such a snippet exported 200 and
re-imported 400.

The walk now descends only under keys whose STRING value is a JSON
document something downstream re-parses: config, events, fields,
metadata, phase_data, plan_overrides, schema, settings, tags, traits.

WHY A LIST IS SAFE HERE, when ValidateQuery's comment rejects exactly
this shape for query parameters: there the set of names is unbounded by
design (parseItemListParams turns any unrecognised parameter into a field
filter), so no list could be complete. Here the set is a closed property
of the wire model — a field is JSON-encoded because a Go struct declares
it as a string holding JSON — and
TestJSONEncodedFieldKeysCoversTheModels derives it from internal/models
and fails when a new one appears. The list cannot go stale in silence.

Over-inclusion is the safe direction and the list takes it: a listed key
that is not really JSON-encoded costs one parse attempt and can only
refuse a complete JSON document carrying the escape, while a missing key
reopens a door. `traits` is listed for that reason — it carries JSON but
its declaration has no comment saying so, which is exactly how the
derivation test would have missed it, so the test asserts coverage in one
direction only and the list is allowed to be a superset.

The walk also changed shape: decoding into `any` and walking the value,
rather than a token stream, because key context is needed to know which
subtree is JSON-encoded. The []byte reasoning is unchanged and still
holds — decoding into `any` never produces a []byte, so a base64 field is
seen as its ASCII text rather than as decoded bytes that might contain a
legitimate 0x00.

Tests: text fields carrying a JSON document are ACCEPTED (five keys),
with a leg proving the same document under a JSON-encoded key is still
refused, so the pair differs only in the key; the derivation test; and
the depth-bound fixture now nests under a JSON-encoded key at every
level, since nesting under an ordinary key would never start the
recursion and would have passed for the wrong reason.

STILL OPEN, and the lead holds it: a LEGACY row whose stored fields blob
already carries the escape still exports 200 and re-imports 400. That is
data this fix cannot make importable without weakening the write-side
refusal, and the disposition (repair sweep, flagged import, or documented
acceptance) is a product ruling. Recorded on the item.

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

* fix(server): close the three body doors codex round 3 found (BUG-2803)

All three verified before fixing, none taken on the reviewer's word.

1. BUNDLE IMPORT BYPASSED THE REFUSAL (P1). handlers_import_bundle.go
parses pad-export.json itself rather than through decodeJSON, so the
SAME workspace import — reached with Content-Type application/gzip
instead of application/json — walked straight past the NUL check into
Postgres. The bundle's export blob is now checked with bodyDecodesNUL
before ImportWorkspace, answering the same 400. Test drives a real
tar.gz through the router with a clean-bundle control leg, because this
path answers 400 for a dozen unrelated reasons (bad gzip, out-of-order
tar, duplicate entries) and a bare 400 would prove nothing.

2. ONE CALLER SWALLOWED THE NEW ERROR (P2). handlers_admin.go's
test-email endpoint read `if err := decodeJSON(...); err != nil ||
input.To == ""` and fell back to the admin's own address, so a body
carrying a NUL answered 200. An ABSENT body legitimately means "send it
to me"; a body that is present and REFUSED is a different thing, and
collapsing the two turns a validation error into a success. The two
cases are now separated on errors.Is(err, io.EOF).

3. THE COMPLETENESS TEST COULD NOT SEE PAST TWO CALL SHAPES (P2). It
scanned for json.NewDecoder(r.Body) and io.ReadAll(r.Body), so it was
blind to io.ReadAll(io.LimitReader(r.Body, n)) — a shape ALREADY in the
package — and to any alias or helper. A completeness test that misses a
live example is worse than none, because it reads as coverage. It now
scans for the thing that cannot be spelled around, a reference to the
request body at all, and requires every FILE touching one to be
accounted for with a written reason. Both directions are asserted: an
unaccounted file fails because a door may have opened, and an accounted
file that no longer touches a body ALSO fails, so the list cannot rot
into stale excuses that quietly cover a future reader. Verified with a
positive control (an added body reference in an unlisted file fails) and
a negative one (a stale entry fails).

FOUND BY THAT WIDENED SWEEP, and fixed here rather than filed: the raw
artifact import (POST /workspaces/{ws}/import-artifact) takes TEXT, not
JSON, so it never went through decodeJSON and inherited neither the NUL
refusal nor the path/query rule — a body is neither. A raw NUL or
invalid UTF-8 reached the store and Postgres answered 22021, which the
handler turned into a 500 for what is a client error. It now applies
bindableText, the same predicate ValidatePath and ValidateQuery use, and
answers 400 invalid_body. Note the shape difference from the JSON half:
there the ESCAPE is the vector because a decoder rejects a raw NUL;
here the RAW BYTE is, because nothing is in the way.

Each fix has a mutation run against it: disabling the bundle guard fails
the bundle test, disabling the artifact guard fails the artifact test,
and both controls still pass.

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

* fix(server): the escape gate was unsound, and YAML has its own (BUG-2803)

Codex round 4, two P1s, both reproduced before fixing.

1. THE FAST PATH LET A REAL NUL THROUGH. bodyDecodesNUL gated on "does
the raw body contain the six-character escape". That is unsound: the
BACKSLASH itself can be written as an escape, so a body carrying
\u0000 contains no literal six-character sequence anywhere in its
raw bytes, while the OUTER decode manufactures one inside the string —
and if that string is re-parsed as a JSON document (jsonEncodedFieldKeys)
the second parse turns it into a real NUL.

Measured through the real router before the fix: the oblique spelling
answered 201 where the direct one answered 400.

The mistake was applying a fact about how a NUL is spelled INSIDE a
decoded string to the RAW BYTES, where the backslash can itself be an
escape. That is the same layer-confusion this whole bug is made of, for
the third round running.

The gate is now a BACKSLASH. Every JSON escape mechanism requires one, so
a body with no backslash has decoded strings byte-identical to its raw
bytes, and a raw NUL cannot survive the decoder — no backslash therefore
means no NUL, at any depth, however spelled. Bodies WITH one pay for an
exact answer, a larger set than before (any nested JSON carries a
backslash-quote), which is the cost of being correct. The same
correction applies to the per-string pre-filter one level down.

2. YAML HAS ITS OWN ESCAPE VOCABULARY. The raw bindableText check added
last commit passes a double-quoted scalar `title: "a\0b"` — no NUL in
the request bytes — and the YAML decode manufactures one. Measured
before the fix: that artifact imported 201 with a NUL in the item title.
The decoded artifact is now checked too: title, body, and every
frontmatter field value, walked because a playbook's `arguments` is a
nested structure rather than a scalar. Keys are checked as well as
values, on the same precautionary grounds ValidateQuery states for
query parameter names.

Same shape as the JSON half in both cases: a value that is harmless
until a SECOND parse, checked at the layer that can see it.

Tests: the oblique spelling joins the nested-document table, and the
YAML escape joins the artifact table. Each is mutation-verified —
reverting the gate to the substring fails the oblique case only, and
disabling the post-decode artifact check fails the YAML case only, with
the raw-byte cases still killed by the raw check. That per-leg
discrimination is the point: it shows each check earns its own keep
rather than being covered by its neighbour.

Prose corrected where this falsified it: jsonNULEscape's "it is the ONLY
spelling" is true of the escape and was being used to justify a filter on
the raw bytes, which is a different claim. Both now say so explicitly.

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

* fix(server): multipart text fields and the bundle manifest (BUG-2803)

Codex round 4's two P2s. Both are the same shape as the rest: a
caller-supplied string reaching a text comparison through a door the
earlier fixes did not cover.

1. MULTIPART TEXT FIELDS. The multipart body is deliberately exempt from
the JSON rule — its payload is binary blob content and must not be
scanned for text validity — but its TEXT fields are a different thing.
`item_id` goes to ResolveItem and into a database comparison exactly as
the query-string channel does, and that channel has been validated at
the transport since BUG-2784; the form channel was not. multipartValues
now drops values that are not bindable text, which makes an unusable
value indistinguishable from an absent one — the disposition
resolveUploadItemID already applies to empty values.

The uploaded FILENAME gets the same predicate, with a fallback to a
generic name rather than a refusal: the bytes are fine, only the label
is unusable.

A NEGATIVE RESULT worth recording, because it changed the test: a RAW
NUL in the multipart header is NOT the vector. Go's multipart reader
refuses it as a malformed MIME header line before any handler sees it
(measured: 400, "malformed MIME header line"). The reachable spelling is
the RFC 5987 encoded form, filename*=UTF-8''sh%00ot.png, which the
header parser accepts and percent-decodes afterwards. The first version
of this test used the raw form and was testing a vector that does not
exist.

2. THE BUNDLE ATTACHMENT MANIFEST. A second JSON document inside the
tar.gz, parsed directly like pad-export.json was, so it needed the same
check. Without it a NUL in a manifest string reached
rehydrateAttachment, whose failure is logged and SKIPPED — so the import
reported success while silently dropping the attachment. The
skip-on-failure behaviour is pre-existing and deliberate (a partial
restore beats none); refusing the bad INPUT is what stops it being
reached this way. Left as it is, and named rather than quietly changed.

A VACUOUS ASSERTION THE MUTATION CAUGHT, recorded because the test would
otherwise have shipped as coverage: the filename leg first asserted
`!strings.ContainsRune(body, 0)` on the RESPONSE, which is JSON — a NUL
in the filename comes back as the six-character escape, not as a 0x00,
so the check passed whether or not the fix was present. It did pass with
the fallback disabled. Now it decodes the response and asserts the
replacement name. The item_id leg had the mirror-image weakness: it
asserted "not a 500", which is the Postgres-only symptom, so on SQLite it
would have passed either way; it now asserts the request behaves exactly
like the no-value control.

Every fix in this commit has a mutation against it, and each kills only
its own leg.

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

* refactor(server): drop the now-unused escape constant (BUG-2803)

The gate became a backslash check, which was the last production use of
jsonNULEscape; golangci-lint's unused check failed on the next run. Its
documentation was load-bearing, so the explanation moved into
bodyDecodesNUL's comment rather than being deleted with the variable —
including the distinction that made the old gate wrong (the escape has
one spelling INSIDE a decoded string, which is not a claim about the raw
bytes).

Caught by re-running lint on the tip after the previous commit rather
than trusting the run from the tip before it.

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

* fix(server): rune-safe truncation and User-Agent sanitising (BUG-2803)

Codex round 5 was asked for the POPULATION rather than a confirmation —
"enumerate every remaining way a caller-supplied string can reach a
database text or jsonb parameter without passing a validity check" — and
returned three residual classes with their sinks. Two are fixed here;
the third is filed, because measuring it needs a fixture this unit
should not grow.

1. TRUNCATION CAN UNDO THE VALIDATION. Four sites cut a caller string
with a plain byte slice (name[:120], input.Name[:200]). If the boundary
lands inside a multi-byte rune the result ends in a partial sequence and
is no longer valid UTF-8 — so a value that PASSED the body check a few
frames earlier arrives at the store unbindable, and Postgres answers
22021 for a request the server already accepted.

This is the interesting one, because no input-side round could have
found it: the defect is downstream of validation, and it is invisible
with ASCII fixtures, which is what every test in that area used.
truncateBindableText walks back off continuation bytes and drops the
straddling rune. Tested with 2-, 3- and 4-byte runes so an off-by-one
walk-back cannot pass them all, and with a counterfactual leg asserting
the naive slice really does produce unbindable output for the same
input — without it the cases would pass against an implementation that
did nothing.

2. USER-AGENT REACHES TEXT COLUMNS. It lands in activities.user_agent
(three document paths, the connected-apps revoke) and
sessions.user_agent (three login paths), and no rule here sees a header.
The disposition is SANITISE, not refuse, and that is deliberate: a
header is metadata this server chose to record, not something the caller
asked for, so a malformed one must not turn an otherwise fine request
into a 400. The two sites that HASH the header are left alone — sha256
over arbitrary bytes is well defined, and changing what is hashed would
invalidate every stored UAHash.

The filing's own earlier probe had recorded User-Agent as NOT
reproducing on the item-create path. That was true and did not
generalise; these are different sinks.

3. NOT FIXED, FILED: the OAuth form-encoded bodies
(/oauth/token, /oauth/authorize/decide, /oauth/revoke,
/oauth/introspect) parse url-encoded form data outside the shared body
validator, with connection_name reaching oauth_connections.name and
client_id reaching the oauth_clients.id lookup. This was the ORIGINAL
subject of BUG-2803 before the filing was re-scoped, and it was recorded
then as unreachable without a fosite-backed fixture. That is still true,
and round 5's sink list is far more than the filing had. Filed rather
than guessed at.

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

* fix(server): narrow the gate, stop refusing natural-shape fields (BUG-2803)

Codex round 6 plus one measurement of my own. Three changes, one of them
a revert of something I got wrong in the previous commit.

1. THE GATE COST TOO MUCH, so it is narrower and still sound. The
previous commit gated the walk on "does the raw body contain a
backslash", which is correct but catches every body carrying nested JSON
(each `\"` is a backslash). Measured on a ~377 KB import-shaped body:
60106 allocs/op with that gate versus 30073 with the walk disabled — the
walk was running on ordinary traffic.

The gate is now the four bytes that begin any \u escape for a character
below U+0100. The argument: to manufacture the six-character NUL escape
inside a decoded string, each of its characters arrives either literally
from the raw bytes — in which case the raw contains the escape, which
begins with that prefix — or from a \u escape of its own, and the three
characters involved (backslash U+005C, 'u' U+0075, '0' U+0030) all sit
below U+0100, so those escapes begin with it too. Back to 30073
allocs/op, identical to the walk-disabled build.

That argument is the same KIND of reasoning that was wrong two rounds
ago, so it does not stand on its own: a differential test runs the gated
function against an UNGATED walk over a corpus built to attack it —
oblique backslash, upper-case hex, an escaped 'u', an escaped '0', a
doubled backslash — and fails on any disagreement. It also asserts the
corpus contains both answers, since agreement over a one-sided corpus
would be vacuous. Reverting the gate to the old substring fails it.

2. THE CHECK REFUSED THE NATURAL SHAPE OF ITS OWN FIELDS. `tags` and
`fields` accept both a JSON-encoded STRING and their natural array/object
form, and the walk propagated "this subtree is JSON-encoded" into
containers — so a free-form tag whose whole value happened to be a JSON
document was refused, though nothing re-parses it. Measured: refused
before, accepted now, while the JSON-encoded spelling of the same field
is still refused. The flag now marks only a direct STRING child of a
listed key.

3. REVERTED: I wired the three LOGIN paths to the User-Agent sanitiser
last commit, before reading store.CreateSession. It HASHES the header
and stores no text — the round-5 enumeration named "sessions.user_agent"
and I took the name for a column. The change would have been actively
harmful: login would store sha256(sanitised) while middleware_auth still
compares sha256(RAW), so every session from a client with a non-UTF-8
User-Agent would fail validation. A sink named in a review is a pointer
to verify, not a finding. The real sink is activities.user_agent, from
three document paths and the connected-apps revoke.

4. And the wiring leg codex asked for, on that real sink: a request
through the router with a malformed header, reading the STORED value out
of the activities row, with a control asserting an ordinary header is
kept VERBATIM. Unwiring the production call site fails it; the helper's
unit test does not notice.

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

* fix(server): apply the key rule at every level, not once (BUG-2803)

Codex round 7, both findings, the first confirmed by measurement.

1. THE RECURSION WENT ONE LEVEL TOO DEEP. Once the walk descended into
a JSON-encoded string it treated the WHOLE subtree below as
JSON-encoded, so a value nested two levels down — an ordinary string
inside a `fields` blob that happens to hold JSON text — was refused.

That is a false rejection, and the measurement says so plainly. With the
depth-2 check disabled, on Postgres 17:

  depth 1 (the fields blob itself)     -> 400   (correct: Postgres parses it)
  depth 2 (a string INSIDE the blob)   -> 201   (accepted, no error)
  control                              -> 201

The handler parses `fields` ONCE. The inner text is re-escaped when the
blob is written, so what Postgres receives has a doubled backslash and no
escape at all. Only the document Postgres itself parses can carry a fatal
one.

The nested call now passes false rather than true, which makes this a KEY
RULE APPLIED AT EVERY LEVEL rather than a depth limit: a JSON-encoded key
INSIDE a document still recurses (pinned by a test), an ordinary one does
not. Same correction as round 6's natural-shape fix, one level further in
— I fixed the sibling case and left this one, which is CONVE-18's lesson
about my own enumeration being a sample too.

I checked whether anything re-parses a value inside the blob before
loosening this, rather than assuming: `arguments` was the candidate, and
parsePlaybookArguments asserts it is a native ARRAY (raw.([]any)) rather
than a JSON string, so it is covered by the natural-shape rule and needs
no second parse.

2. AN ERROR MESSAGE THAT SENT CLIENTS THE WRONG WAY. The OAuth dynamic
client registration handler prefixed every decode failure with "Request
body must be JSON". A body carrying a NUL is valid JSON, so that message
sends a client hunting a syntax error it does not have. The two failures
are now distinguished.

Round 7 also reports no break in normal CLI, MCP or web-client request
generation — they marshal JSON and encode paths and query parameters —
which is the first thing any round has said about the client surface.

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

* fix(server): complete the artifact check, make the walk path-aware (BUG-2803)

Codex round 8. It confirmed round 7's two fixes, then found two real
defects and two inaccurate comments — the comment half being the angle
the round was asked for.

1. THE ARTIFACT CHECK MISSED TWO REACHABLE FIELDS. artifactIsBindableText
walked the decoded artifact by TYPE, so it never covered Provenance —
whose strings are rendered into a Markdown footer appended to the stored
content — and never matched Arguments, declared []map[string]any, a
concrete slice type the walk's []any case does not match. A YAML NUL
escape in either reached storage.

It now MARSHALS the artifact and searches the output for the escape
encoding/json produces. A type switch over a struct that grows is a list
that goes stale in silence; marshalling covers every exported field,
including ones added later. The one thing it cannot see is invalid UTF-8
(which marshals to U+FFFD), and it does not need to: step 2 rejects that
in the request bytes, and YAML cannot manufacture it from valid input —
its escapes name code points, where \0 names a NUL.

Both new cases fail with the check disabled; the raw-byte cases still
pass, killed by the raw check, so each leg is discriminating.

2. THE WALK WAS NOT PATH-AWARE. A collection may declare a user field
literally named `schema` or `tags`. The walk consulted the wire-key list
at every level, so `{"fields":{"schema":"..."}}` treated a user field
name as a wire key and refused valid text holding a JSON example.

The key list is now consulted only OUTSIDE caller data — not under a
natural `fields` object, not inside an element of a `tags` array, not
inside a re-parsed document. Combined with round 7's fix that makes the
descent exactly one level deep BY CONSTRUCTION, which is why the depth
counter is gone: with the flag no longer inherited, a bound could never
fire, and dead protection reads as protection. The depth-bound test is
replaced by one that pins the property directly — an escape IN the
parsed document is refused, one BELOW it is accepted, and a
wire-key-shaped user field does not restart the descent.

3. THREE COMMENTS CORRECTED, all mine, all of the kind a reader would
believe without checking:

- MaxBytesReader: Close FORWARDS to the underlying body rather than
  being a no-op, and with a nil writer there is no automatic 413 — the
  cap surfaces as a read error the callers turn into 400. Behaviour
  unchanged; only the claim was wrong.
- parseArtifactRequest said "three checks" while implementing five, and
  its returns list omitted ErrArtifactUnbindableText. Both added by this
  branch, which is exactly the prose a change is most likely to falsify
  (CONVE-23).
- errJSONBodyNUL claimed all 65 callers surface its message. The STATUS
  is uniform; the wording is not — several substitute a generic string.

4. And one in a test: the timeline fixture said both backends hold a
CHECK constraint a raw NUL violates. items.fields is a plain TEXT column
with no CHECK on SQLite. What was OBSERVED is "SQL logic error:
malformed JSON"; the likely source is an expression index over
json_extract, and that attribution is recorded as NOT verified rather
than asserted.

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

* fix(server): a regression this branch introduced, and the same trap again (BUG-2803)

Codex round 9. Both findings are mine, one of them a regression from the
round-8 restructure two commits ago.

1. THE ROUND-8 RESTRUCTURE REOPENED THE ORIGINAL DOOR. Taking the
JSON-encoded branch for a listed key skipped the plain "does this string
contain a NUL" check and asked only "does the document this string
carries hold an escape". Those are different questions. So
{"fields":"a<NUL escape>b"} — a direct NUL in the fields value, the very
first case this whole change closed — was accepted again.

Both checks now run. The test pins all three legs: a direct NUL in the
fields string, an escape inside the fields document, and an ordinary
fields string that must still be accepted, so the first two cannot pass
merely because everything under a listed key is refused.

2. THE ARTIFACT CHECK FELL INTO THE TRAP IT WAS WRITTEN AGAINST. It
searched the MARSHALLED bytes for the escape sequence, and a value
holding the six LITERAL characters marshals to a doubled backslash which
still contains that sequence as a substring — so valid content was
refused. Artifacts are documentation; text about a JSON escape is
exactly what one carries.

Worse than the bug: the comment I wrote asserted the ambiguity "cannot
arise here". It was the same doubled-backslash case bodyDecodesNUL exists
to resolve, one function away, and I wrote a sentence explaining why it
did not apply instead of checking. The marshalled form is now decoded
again and walked with the same machinery — the round trip is what makes
every field reachable without a type switch, the walk is what makes the
answer exact.

Its test asserts literal escape TEXT is accepted in title, body and a
field value, with a counterfactual leg asserting a real NUL in each of
those places is still refused, so acceptance cannot come from the check
doing nothing.

Reverting either fix fails its test and only its test.

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

* docs(backup): the one case where an export is not importable (BUG-2803)

Codex round 12, an operational pass. It found no migration or config
requirement, and two documentation gaps.

docs/backup.md promises that application-level export/import is portable
across SQLite and PostgreSQL. Since BUG-2803 that has one exception: a
workspace whose stored data contains a NUL exports fine and is refused on
import. It can only affect data written before the rule existed and only
on SQLite, which accepted it — a PostgreSQL instance never stored one.

`pad db migrate-to-pg` has the SAME problem and reports it worse: it
copies rows directly and never passes through the import guard, so a
legacy row fails against PostgreSQL's JSONB parser partway through the
copy rather than being refused up front. That is the likelier way an
operator meets this, since it is the operation that puts an entire old
SQLite database in front of PostgreSQL for the first time. Recorded on
BUG-2810, which owns the preflight and repair.

Round 12's other finding — that the PR's stated release note covered the
JSON 500-to-400 change and none of the rest — is fixed in the PR body
rather than in the tree.

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

* test(server): close two blind spots the tests themselves had (BUG-2803)

Codex round 13, asked whether the new TESTS are sound. Five findings;
these are the two that were self-contained. The other three are recorded
on the item with what each needs.

1. THE COMPLETENESS SCAN WAS BLIND TO FORM BODIES. It matched only
`.Body`, so FormValue / ParseForm / MultipartForm — which read the
request body just as surely — were invisible. It therefore reported full
coverage while the OAuth form-encoded handlers were entirely outside its
view. Widened, and it immediately failed on handlers_oauth.go, which is
the instrument working.

That file is now ACCOUNTED FOR AS A KNOWN GAP rather than as safe: the
OAuth handlers read form-encoded bodies that no rule in this family
covers (the transport rules see the query half of r.Form, not the body
half), tracked as BUG-2811 and needing a fosite-backed fixture to
measure. The test now STATES the gap instead of being blind to it, which
is the difference between a completeness claim and a completeness
appearance.

2. THE TRUNCATION TEST ADMITTED AN IMPLEMENTATION THAT RETURNED "". Its
assertions were: within the limit, bindable text, a prefix of the input.
An empty string satisfies all three. It now also asserts that an input
fitting the limit comes back UNCHANGED, and that no more than one rune
(4 bytes) is lost to the boundary — so a truncator that drops too much
fails, not just one that keeps too much.

Both were found by asking whether a broken implementation would pass,
which is the question CONVE-12 is about and which I had applied to the
production code and not to these two tests.

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

* test(server): the three remaining round-13 gaps (BUG-2803)

Codex round 13's other three findings, all of the same shape: a test
that would stay green with the production change reverted.

1. THE MANIFEST CHECK WAS UNTESTED. The bundle test built archives
containing only pad-export.json, so disabling the INDEPENDENT attachment-
manifest check left the suite green. The new test builds a bundle with
both entries, differing only in the manifest, so a refusal cannot come
from the export half. Verified by disabling each check separately: only
the matching test fails, so the two are independently covered.

2. THE TEST-EMAIL CHANGE HAD NO HANDLER-LEVEL TEST. Every existing leg
exercised decodeJSON, so reverting handlers_admin.go to default EVERY
decode failure to the admin's own address passed them all. The new test
drives the real endpoint with a wired mock sender and pins the
distinction that used to collapse: an ABSENT body still means "send it
to me" (control), an ordinary body still sends (control), and a body that
is present and refused answers 400 rather than being reinterpreted as
the default recipient.

3. THE MULTIPART LEG CHECKED ONE BYTE CLASS. A filter rejecting NULs
while letting malformed UTF-8 through would have passed it. It now drives
both, which matters because invalid UTF-8 is the class that reaches
Postgres as 22021 on a UTF8 database.

Round 13 was asked whether the new TESTS are sound — deterministic,
order-independent, and failing on broken code. It reported the fixtures
isolated and found five ways they were not discriminating. Two were
fixed in the previous commit; these are the rest.

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

* test(server): pin the wiring at every call site, not one (BUG-2803)

Codex round 14 confirmed round 13's five, then found the same shape one
level out: reverting a SINGLE call site back to the unsafe form left the
whole suite green, because the surviving fixtures are ASCII and a
helper's unit test does not care who calls it.

TestTextSafeHelpersAreUsedAtEveryCallSite asserts the wiring STATICALLY
rather than adding a fixture per site (an OAuth connection, a cloud
login, four audit paths). A byte-slice truncation of a caller string
fails it, and so does a raw User-Agent read outside the exempt set. Both
directions are checked: finding none of the SAFE form also fails, so a
scan that silently matched nothing cannot pass forever.

The User-Agent exemptions carry counts rather than being blanket, so a
NEW raw read in an exempt file still fails. All four reads in
handlers_auth.go are exempt because they feed a HASH — CreateSession
hashes the header and stores no text — and sanitising before hashing
would be actively harmful: login would store sha256(sanitised) while the
session check still hashes the RAW header, failing validation for every
client with a non-UTF-8 User-Agent. middleware_request_text.go's one raw
read is requestUserAgent itself.

Verified by reverting one truncation call site and one User-Agent call
site independently; each fails the test.

Round 14's third finding is fixed behaviourally rather than statically,
because the static scan cannot see it — handlers_oauth.go is already
listed for its form-body reads. TestOAuthRegisterRefusesNULBody drives
the real dynamic-registration endpoint with cloud mode and an OAuth
server wired, with a control leg registering successfully, and pins both
the refusal and the message split: the body IS valid JSON, so the answer
must not send a client hunting a syntax error.

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

* fix(server): match wire keys the way the decoder does (BUG-2803)

Codex round 16, asked whether this change is consistent with its siblings
in the same file and extensible by someone who did not write it. It found
a live bypass instead.

encoding/json matches an incoming key to a struct field by an exact match
first and a CASE-INSENSITIVE one otherwise, so {"Fields":...} and
{"FIELDS":...} land in ItemCreate.Fields exactly as {"fields":...} does.
The walk looked the key up case-SENSITIVELY, so it skipped the nested
document for a body the handler went on to accept, and the database
answered the original 500.

Measured before the fix: `fields` refused, `Fields` and `FIELDS`
accepted.

This is the same defect shape as everything else in this unit — a check
that agrees with one layer's rules while the layer that actually consumes
the value uses different ones — which is why the fix is a PREDICATE
rather than a wider map: the map is the vocabulary, and the matching RULE
belongs to the consumer. Someone adding a key should not also have to
remember to add its spellings.

The test drives six spellings including mixed case, with a control
asserting an unlisted key stays caller data in any casing, so this is
case-insensitive matching rather than matching everything.

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

* fix(server): fold keys the way encoding/json folds them (BUG-2803)

Codex round 17, first of five findings. The previous commit fixed the
ASCII half of key matching and left the Unicode half, which is this
bug's own pattern one more time.

encoding/json matches with Unicode SIMPLE FOLDING, not lower-casing.
U+017F LATIN SMALL LETTER LONG S folds to 's', so "ſchema" reaches the
`schema` struct field while strings.ToLower("ſchema") is unchanged and
missed the allowlist — a nested NUL under that spelling reached the
handler undetected.

Matching is now strings.EqualFold against each canonical key. The test
carries both a lower-case fold spelling and an upper-case one alongside
the ASCII cases, and keeps its control asserting an unlisted key stays
caller data in any casing.

The other four round-17 findings are recorded on the item rather than
patched here: they are genuine layer disagreements (duplicate keys
merging differently in a typed decode than in a map, a scan-failure
disposition on inputs the typed decode tolerates, and unknown-field
policy) whose fixes are design decisions rather than corrections, and
this seat is near its context bar. Each is written up with the
measurement it needs.

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

* fix(server): pin that a NUL-bearing manifest refusal keeps the partial workspace (BUG-2803)

Codex round 18. The comment on the manifest NUL branch said refusing the
input "stops it from being reached this way" and stopped there, which
reads as though the refusal undoes the import. It does not.

A plain error with a non-nil workspace keeps the partial workspace,
exactly as every other manifest failure in this loop does — the rollback
branch fires only for *importStatusError, and mid-stream manifest
failures intentionally keep what was imported (TASK-896). Returning a
rollback-shaped error here would give NUL-bearing manifests different
semantics from malformed ones, which is a change to the bundle-import
contract rather than a fix to this bug.

So the behaviour is unchanged and now DELIBERATE: the comment states it,
and the test asserts the persisted state rather than only the HTTP
answer. Mutation: routing the branch through *importStatusError makes
the refusal roll back, and the new assertion fails naming the release
note it would falsify. The pre-existing status/body assertions do not
notice.

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

* docs(server): record the four map-model disagreements as dispositions, and pin them (BUG-2803)

Lead ruling day-68 is land-and-follow: this branch lands on its measured
commits, and the token-stream rewrite is the BUG-2812 unit's spec rather
than a late restructure of an 18-commit branch under review pressure.
That makes the four open findings from rounds 16-17 something to WRITE
DOWN precisely, not something to leave in a trail comment.

The doc comment on bodyDecodesNUL now carries all four, with the one
root cause named: this scan decodes into map[string]any and the typed
decode does not agree with that model about keys. Two under-refuse
(duplicate-key merge; scan-failure passthrough) and are BUG-2812's spec
- both dissolve under a walk that never builds values. Two over-refuse
(unknown fields; case-variant duplicates) and are ACCEPTED, because
refusing is the safe direction. The asymmetry is stated rather than
smoothed over: within the map model, (1) and (4) are one defect seen
from two sides and only one of them fails safe.

Finding (3) is an observable compatibility change - a forward-compatible
field carrying a NUL escape now gets a 400 where it got a 200 - so it
goes in the release note as well as here. A qualification only protects
where the actor meets it.

All four are pinned by a test, measured on this tip rather than carried
over from the round-16/17 write-up. The two known-gap legs assert the
WRONG answer on purpose: when BUG-2812 lands they FAIL, naming the doc
comment and the release note as what to update. Both gap legs carry a
premise assertion - the same bodies with the disagreement mechanism
removed ARE detected - without which they would pass against a check
that detected nothing.

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

* test(server): wire release-note item 10 to the router, with its before-state measured (BUG-2803)

The disposition test proves bodyDecodesNUL RETURNS true for an unknown
field carrying a NUL escape. The release note claims the API answers
400. Those are different claims and only the second one is what an
operator or client author reads - CONVE-19, my own convention: a
direct-call test vouches for the component, not its binding.

Two legs, and the control is the load-bearing one. An unknown field with
an ordinary value must still be ACCEPTED, so this pins "refused for the
NUL" rather than "refused for being unknown". The handler does not
reject unknown fields; if it ever started to, the note's explanation
would be wrong while its status code stayed right, and no
status-code-only assertion could see that.

The before-state is measured rather than asserted from memory. Disabling
the check makes the same request answer 201 - which is main's behaviour,
since decodeJSONWithLimit there unmarshals straight into the typed value
and the key is dropped. So "answers 400 where it answered 200" is a
measurement in both directions, not a recollection of one.

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

* docs(backup): the NUL rule lives in the binary, not the database (BUG-2803, BUG-2813)

Codex round 19, the fresh-angle deploy/rollback/mixed-version pass.

docs/backup.md said a NUL-bearing row "can only affect data written
before that rule existed, and only on SQLite". The second half is true.
The first half is false, and the reason is the interesting part: the
guard is in decodeJSONWithLimit, so the invariant is a property of the
running BINARY, not of the database.

On SQLite any window where an older binary serves the same database can
still write one - a rollback after upgrading, a staged rollout with an
old and a new instance sharing a database, a second older instance on
the same file. The window closes, the guard returns, and the rows are
already stored, behaving exactly like genuinely old ones. A rollback is
an ordinary operational move, so this is not an exotic path.

The doc now states the binary-version dependence, says which dialect is
affected and why PostgreSQL is not (it refuses a NUL itself, at every
version), and gives the operational answer: drain writes from older
binaries before the new one serves, or roll forward rather than back.

Store-layer enforcement - so the running build stops mattering - is
filed as BUG-2813 rather than added here. It is a dialect-level change
and the day-68 ruling on this unit is land-and-follow.

The same false implication was carried by the PR's release note calling
such a workspace "legacy"; corrected there too.

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

* docs(server): cite the ruling in house style, not the team-room day counter (BUG-2803)

"lead ruling day-68" is the internal day counter, which means nothing to
anyone reading this repo and is inconsistent with every other citation
in it - the codebase cites a lead ruling by DATE or by BUG ref, never by
day-N. Replaced with the bug ref, which is the part a reader can
actually follow.

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

* docs(server): drop a commit count I had already measured as wrong, and stop asserting a cause I borrowed (BUG-2803)

Two defects in a comment I wrote an hour ago, both of the kind this
unit's trail keeps recording.

"an 18-commit branch" - the branch was 20 commits at b0192871 when I
counted it this session, and is more now. 18 came from the previous
checkpoint's own miscount, which I had ALREADY identified and written up
before I typed it again here. A number that arrives inside a sentence
about something else does not feel like a claim, which is exactly why it
survives. The count is incidental to the argument, so it is gone rather
than corrected - a figure that has to be maintained to stay true is a
liability in a doc comment.

"this branch's one regression came from exactly that" - the ruling's
reasoning, restated by me as a verified fact. The regression I know
about came from wiring a fix off a reviewer-named sink list without
reading the mechanism, which is adjacent to "restructuring late under
review pressure" but is not the same mechanism, and I did not check
whether it is the one the ruling meant. Now attributed to the ruling and
stated as its reasoning, with the part I can defend - the review loop
finding something in nearly every round indicates a design problem -
carrying the argument.

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

* fix(server): sanitise the MCP audit tool_name, and correct three claims wider than their evidence (BUG-2803)

Codex round 20, asked for a POPULATION rather than a confirmation
(CONVE-24). It returned a covered list AND four findings; this commit
carries the two that belong to this unit plus the doc corrections.

## The door: MCP audit is a second reader, not a pass-through

parseMCPRequestBody runs its OWN json.Unmarshal and binds the decoded
method / params.name to mcp_audit_log.tool_name, TEXT NOT NULL. A
six-character NUL escape therefore arrives as a real NUL: PostgreSQL
refuses the audit INSERT with 22021 - the exact symptom this unit exists
to remove - and SQLite stores an unprintable tool name. Nothing upstream
catches it; the /mcp transport decodes the JSON-RPC envelope itself
rather than through decodeJSON, so the body rule never sees the request.

Measured before fixing: the decoded name reached the column intact.

This unit's own completeness map had CERTIFIED that reader as safe, on
the grounds that "decoding still happens in the MCP dispatcher". That is
true and it does not bear on what this middleware persists - a correct
description of a mechanism, with no question asked about what it does,
sitting in the one artifact whose job is to say the population is
covered. Corrected there too.

Disposition is SANITISE, not refuse, following the User-Agent precedent
from earlier in this unit, and the rule now lives in one extracted
helper (sanitiseStoredText) with the reasoning attached: the body rule
refuses because the caller asked to store that value; this serves
metadata the SERVER elected to record, where failing the write would
lose the audit row for precisely the request most worth auditing.

Both caller-derived returns are cleaned inside parseMCPRequestBody, so
both call sites - the ok path and the denied path - are covered at the
choke point rather than at either caller. Both are tested: params.name
AND the method path. Mutations un-sanitising each one compile and kill
only their own leg.

## Three claims corrected, all wider than their evidence

- "all 65 call sites" in server.go: measured 70. Removed rather than
  corrected, because the number has to be maintained to stay true and
  says nothing the sentence needs.
- docs/backup.md said a NUL "cannot be stored in a text or JSON column"
  absolutely, two paragraphs above my own text explaining that SQLite
  accepts one. Now stated as what it is: an application rule Pad
  enforces on both dialects, which is exactly why it has to be enforced.
- artifact_import.go said such a value "cannot be stored under any
  encoding this product supports". Refuses, not cannot - stating a
  policy as a capability tells the next reader SQLite enforces
  something it does not.

## Filed, not fixed

BUG-2814 - guarded writes re-emit at-rest NULs (move/copy/restore/
fields-patch), propagating a legacy value to rows that never had one.
Distinct from BUG-2813: that one is about writing a NUL while an old
binary serves, this is the fixed binary SPREADING one already present.
Both dissolve under the same store-layer enforcement, so they are filed
to be designed together rather than patched at each of a long and moving
list of re-emit sites.

Declined: round 20 also reported the release-note assertions as
unsupported. They live in the PR body, which a read-only sandbox cannot
see - the claim is about the reviewer's visibility, not the diff.

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

* fix(server): sanitise before testing for emptiness, so the audit fallback survives (BUG-2803)

Codex round 21 ranked this the most dangerous un-probed lens, and it is
a boundary my own round-20 fix created.

parseMCPRequestBody tested env.Method == "" and p.Name == "" BEFORE
sanitising. A value made entirely of NUL escapes is non-empty as
decoded and empty once cleaned, so it passed over the fallback and was
then blanked - storing an empty tool_name in a TEXT NOT NULL column.
That is exactly the silent drop the "(unknown)" / "tools/call"
fallbacks exist to prevent; the function's own doc comment says so.

Measured before fixing: both shapes returned an empty tool_name.

Fixed by ordering rather than by adding guards - clean first, then test
- so the invariant is structural instead of something each return has
to remember. Same by-construction preference as the symmetric-gate fix
earlier in this unit.

Worth recording that my first patch was WRONG in a way that compiled:
I put the sanitise above the json.Unmarshal that populates env, so the
method would always have been empty. Caught by printing the patched
function and reading it, not by trusting the script saying "patched".

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

* fix(server): classify MCP audit on the raw method, and trim only JSON whitespace (BUG-2803)

Codex round 22. Two P2s, both measured before fixing.

## A forgeable audit row - my own regression from the round-21 fix

The round-21 change reordered sanitise-before-compare so the fallback
would survive an all-NUL value. That reorder made the CLASSIFICATION
read the sanitised method, so "tools/<NUL>call" cleaned up INTO the
literal "tools/call" and the parser then lifted params.name and hashed
the arguments for a method that was never tools/call.

Measured: tool_name="pad_item" with a full 64-character args_hash - an
audit row indistinguishable from a genuine pad_item call, mintable by
anyone who can send a request. Worse than the review described it.

Fixed by splitting the two jobs, which were never the same job:
dispatch decisions read what the client actually SENT; sanitising is
for the value that gets STORED. The round-21 boundary is preserved -
a method empty only after cleaning still falls back to "(unknown)".

Fixing one boundary and creating another in the same function is worth
naming: the reorder was correct for the case it addressed and I did not
ask what else read that value.

## Go whitespace is not JSON whitespace

The empty-body shortcut used bytes.TrimSpace, i.e. unicode.IsSpace,
which strips \v, \f, U+00A0 and more. encoding/json accepts none of
them. So a body of just \v trimmed to empty, returned io.EOF, and an
EOF-tolerant caller - playbook run treats errors.Is(err, io.EOF) as "no
arguments supplied" and runs anyway - took a syntactically invalid body
for an ABSENT one.

Now trims exactly the four bytes JSON calls whitespace. The test drives
both directions, because only the pair discriminates: real JSON
whitespace must still shortcut to EOF or the playbook contract breaks,
and non-JSON whitespace must not or the divergence survives. Reverting
to TrimSpace compiles and fails three legs.

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

* test(server): give the walker an independent oracle, not one that shares its code (BUG-2803)

Codex round 22, finding 3. TestBodyDecodesNULGateAgreesWithAnUngatedWalk
compares the gated function against an "ungated" reference that calls
the SAME production valueDecodesNUL. That is valid for what the test
claims - it pins the raw-prefix GATE - but it structurally cannot see a
defect in the WALKER, because such a defect is present identically on
both sides and cancels.

That matters here specifically: every walker defect this unit has had
lived in traversal, descent, or key matching (rounds 1, 2, 4, 16, 17),
which is exactly the part the differential cannot check.

Added a second implementation of the contract, written in the test and
deliberately not calling the production walker. It shares encoding/json
and jsonEncodedFieldKeys; it does NOT share traversal, descent, or
key-matching. It is iterative with an explicit stack rather than
recursive, so a recursion-shaped bug cannot reproduce in it by accident.

Demonstrated rather than argued. With the nested-document descent
removed from the production walker - a mutant that reopens the exact
door this unit exists to close, and which compiles:

  differential (gate vs ungated)   ok      <- blind, as the finding said
  independent oracle               FAIL    <- catches it

The corpus is also asserted to contain BOTH answers, since two walkers
that always answer false agree perfectly.

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

* test(server): make the body-reader inventory type-aware, and state what it still cannot see (BUG-2803)

Codex round 22, finding 4. The inventory that claims every request-body
reader is accounted for was lexical, and wrong in three ways - all in
the direction that matters for a test whose job is to say nothing is
invisible:

  - it recognised only the variable names r and req, so a handler
    holding its request as httpReq or orig was INVISIBLE;
  - it matched inside COMMENTS, so prose could make a file look scanned;
  - the manually-listed traits field was already evidence of the
    model-regex blind spot.

My first fix broadened the pattern to any identifier. That was worse,
and worth recording: it matched every unrelated .Body field - input.Body
in comments, fetched.Body in url import, comment.Body, art.Body,
sidecarErr.Body - flagging five files that read no request body at all.
The only route to green would have been listing those five as
accounted, and an accounting entry HIDES future readers in its file. A
false entry is worse than a missing one, so I abandoned that approach
rather than tuning the regex.

Now keyed on the TYPE via go/ast: collect identifiers declared
*http.Request in a function signature, then find reader selectors on
exactly those identifiers. Names stop mattering, comments are not in the
AST, and .Body on anything else is not a match.

Positive control, run rather than argued: a handler taking httpReq
*http.Request and reading httpReq.Body is FLAGGED by the new scan, and
matched zero times by the old regex.

Two limits now stated in the test, because an unqualified completeness
claim is exactly how the MCP audit reader got certified safe while
persisting a decoded NUL: accounting is per FILE rather than per call
site, and only signature-declared requests are seen - one stashed in a
struct field or captured by a closure is not a parameter.

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

* fix(server): mark a cleaned audit identity, and repair two vacuous tests of my own (BUG-2803)

Codex round 23, plus a defect in my own instruments that the mutation
matrix found and the tests hid.

## Cleaning is lossy, so a cleaned identity was forgeable

Round 22 closed the coarse version: sanitising before classifying let
"tools/<NUL>call" become a genuine tools/call. Classifying on the raw
method fixed that. But sanitising still COLLAPSES distinct inputs onto
one output, so "pad_<NUL>item" stored exactly what "pad_item" stores -
same tool_name, same args_hash - and anyone able to send a request could
mint an audit row and a Prometheus label attributed to a real call.

Cleaning and identity are different jobs. sanitiseStoredTextChanged now
reports whether anything was removed, and an identity that only became
well-formed by cleaning is marked. The cleaned text is kept, so the row
stays diagnosable; the marker keeps it distinguishable. Descriptive text
(User-Agent) keeps the unmarked helper - nothing decides anything on it.

The parenthesised form is what this file already uses for a synthesised
value, and a real method or tool name does not begin with "(", so the
marker cannot itself be forged by choosing a clever name.

## Two of my own tests were vacuous, found by a surviving mutant

I wrote nul := "\u0000" in the round-21 and round-23 tests, which in Go
is the NUL CHARACTER, not the six-character escape text. Those bodies
were malformed JSON that encoding/json rejected, so neither test ever
reached the path it named. The comment on the line said "the escape, not
the character"; the code did the opposite, and the correct form was
already three lines away in the round-20 test.

Nothing in the test output showed this. It surfaced only because the
marker mutation SURVIVED, and because a surviving mutant was treated as
a question - does the test not discriminate, or did it not run - rather
than as either answer.

Both repaired and both now kill their mutants: removing the marker fails
with tool_name="pad_item" and a matching 64-character hash; removing
the emptiness guard fails with "(sanitised) " instead of "(unknown)".

Correction for the record: the round-21 checkpoint said that fix was
measured failing before the fix. That measurement used the broken
literal. The finding was real and the fix is right, but it is only
properly established as of this commit.

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

* test(server): use the canonical escNULLiteral helper, not a local literal (BUG-2803)

The helper is assembled from bytes precisely so this escape cannot decay
into the NUL character it describes, and its comment says so: written as
a Go literal it is one backslash away from being the NUL itself.

I rolled a local one in three tests anyway, and two of them decayed
exactly as that comment predicted - vacuous until the mutation matrix
caught them. The safeguard existed, was documented, and I walked past
it; using it is the only version of this fix that cannot recur.

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

* fix(metrics): bound the cleaned-identity marker as a metric label, and correct a false cardinality claim (BUG-2803, BUG-2817)

Codex round 24, which enumerated CONSUMERS of the values this unit
changed rather than asking again whether the guard is right. Most of
that enumeration came back FINE, which is the useful half; three
findings did not.

## The marker must not reach Prometheus as part of a name

The cleaned-identity marker is right for the audit ROW - an operator
reading one row needs to know which tool it resembles. It is wrong for
a metric SERIES: "(sanitised) pad_item" and "pad_item" would be two
series per user and per status, for a distinction no aggregate query
asks. metricsToolLabel collapses the marked form to the bare marker, so
it costs exactly ONE extra label value in total and that value is a
constant rather than anything a caller supplies.

Two tests, and the second exists because the first is not enough. The
direct-call test proves the collapse function collapses. The WIRING test
proves the emit path calls it - CONVE-19, my own convention. Measured:
with the call removed from recordMCPCallMetrics, the direct-call test
stays green and the wiring test fails naming the leaked label.

## A cardinality claim that was never true

internal/metrics documented the tool label as "bounded by the catalog
(~7 tools today)" with arithmetic resting on that. The value is
whatever the caller put in params.name, recorded even for requests that
dispatch later rejects, so an authenticated caller can mint a series per
request. The comment now says so and points at BUG-2817, filed with the
fix shape and the two wrinkles it has to decide - the catalog lives in
internal/mcp, and legitimate JSON-RPC methods are not catalog tools.

That unboundedness is PRE-EXISTING and not this unit's to fix; bounding
the marker's own contribution is, which is why the collapse is here and
the rest is filed.

Also corrected: I wrote BUG-2815 into two comments before filing, and
the filing came back BUG-2817. Predicting an identifier is the same
class of claim as predicting a count.

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

* fix: keep a storable extension in the filename fallback, and sync the rename draft (BUG-2803)

Codex round 24, the two remaining consumer findings. Both trace to this
unit, and both are cases where a value was made SAFE without asking what
reads it.

## The filename fallback was lossier than its sibling

An unstorable upload name became a bare "upload" - no extension - while
the empty-name fallback two lines below has always produced
"upload.bin". The unusable part of "sh<NUL>ot.png" is the STEM; ".png"
is ordinary text, and it is what consumers dispatch on:
Content-Disposition, the web download anchor, bundle export naming, and
, whose documented contract is handing a path to
something that opens files by extension. That command was measurably
affected - it treats any non-empty stored name as authoritative, so its
MIME-based extension fallback never ran and the temp file was
extensionless.

Fixed at the source: a storable extension survives the fallback,
bounded to 16 bytes so a hostile name cannot smuggle a long tail
through. The CLI keeps a defensive extension fallback for any
extensionless stored name, which also covers rows written before this.

Both directions are tested: "sh<NUL>ot.png" now stores "upload.png",
and "shot.p<NUL>ng" - where the EXTENSION is the unusable part - still
stores bare "upload". Without the second leg, "keep the extension"
could quietly become "keep whatever trails the last dot" and reintroduce
the value the fallback exists to remove. Dropping the extension again
fails the first leg.

## A rename that could never come clean

saveName replaced the app object but never updated the draft, so when
the server normalised the name the draft stayed as typed, the equality
check never matched, Save stayed enabled, and each press re-sent the
same request. The server caps at 120 BYTES via rune-safe truncation
while the input allows 120 CHARACTERS, so any multibyte name near the
limit diverges.

The draft is now assigned the value the server actually STORED rather
than compared for length, which stays correct for any future
normalisation. svelte-check: 0 errors.

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

* fix: reserve the synthesised-value namespace, and stop a fallback carrying an unvetted extension (BUG-2803, BUG-2818, BUG-2819)

Codex round 25, which probed whether the values this unit SYNTHESISES
can themselves be attacked. Earlier rounds asked whether the guard
refuses bad input; this asked what the substitutes are worth.

## A fallback must not carry an extension the product would refuse

Preserving a storable extension was right; bindableText was the wrong
bar for it. Control characters are valid UTF-8 and not NUL, so they are
storable - and they are STRIPPED when the name is written into
Content-Disposition. So ".s<VT>vg" passes the extension blocklist, which
sees no known extension, and reaches the client as ".svg".

attachments.SafeFallbackExtension now requires a KNOWN, ALLOWED
extension, so a synthesised name can only carry a suffix the product
already accepts on the ordinary path. Tested both ways: an obfuscated
.svg and an unknown .foo are both dropped to bare "upload", while
.png still survives.

That divergence is PRE-EXISTING on the ordinary path, where the caller's
name is stored as given and no fallback is involved - filed as BUG-2818
with the fix shape. This change only declines to add a second door.

## A mutation exposed a guard that could not fire

I first wrote an explicit alphanumeric loop in that predicate as well.
Removing it changed nothing: no key in extMIMEMap contains a
non-alphanumeric character, so the map lookup already excluded every
obfuscated suffix. Keeping an unreachable guard whose comment claims it
stops control characters would have misdescribed which line does the
work - so the loop is gone, and TestExtMIMEMapKeysArePlain enforces the
property it was relying on. A guard that survives its own mutation is a
question, not a clearance.

## The marker was forgeable, so the namespace is reserved

Marking only what cleaning changed was not enough. A caller may name a
tool "(unknown)" - what the parser returns for a malformed body - or
"(sanitised) pad_item", and a genuine request then records the same
identity as a substituted one. The older sentinels always had this;
the new marker inherited it.

A leading "(" is now reserved for values this server synthesises, and
any caller value entering that namespace is marked too, so the two
never collide. Cost stated: an MCP tool genuinely named with a leading
"(" is recorded marked; tool names are identifiers in every catalog
this server knows.

The principled fix for the whole class is a provenance FIELD rather than
sentinel strings in a caller-controlled namespace. That is BUG-2819 - it
is a migration on two tables, and the same trick cannot rescue attachment
filenames, which are legitimately named with parentheses.

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

* test(server): fix the independent oracle, which was wrong in a branch its corpus omitted (BUG-2803)

Codex round 26, finding 5, and it lands on the instrument I introduced
two rounds ago to check the walker.

The oracle descended into a listed key's JSON document whenever it met
one - including when that key appeared INSIDE a natural object that was
itself under a listed key. Production does not: a natural object or
array under a listed key is USER DATA, because the server marshals it
and nothing re-parses it, so a listed key appearing inside it is an
ordinary field name rather than a document marker.

Measured on {"fields":{"schema":"<escape text>"}}: production=false,
oracle=true. Production is RIGHT and the oracle was wrong, so had that
body been in the corpus the test would have failed and pointed at the
production walker.

It was not in the corpus. That is the part worth keeping: the test
already asserted its corpus was not one-sided - that BOTH answers
appear - and that check passed while a whole branch of the contract went
unexercised. Both answers appearing is not the same property as every
branch being covered, and I had treated it as though it were.

Fixed by giving the oracle the same user-data rule, and both bodies are
now in the corpus - the natural-object case that must answer false, and
its string-valued counterpart that must answer true.

Re-verified that the correction did not blunt it: with the production
nested descent removed, the oracle still fails.

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

* fix: refuse path-component filenames, and single-source the MIME extension table (BUG-2803)

Codex round 26, findings 2 and 3.

## ".." is not a filename, it is a path component

The server guard listed "", "." and "/" but not "..", which survives
bindableText. filepath.Ext("..") is "." - non-empty - so an extension
check waves it through too, and a consumer joining it onto a directory
gets that directory's PARENT. The CLI builds its temp path exactly that
way.

Both ends fixed, deliberately independently. The server now rejects any
name that is only dots or carries a separator, checked on the trimmed
form so "..." and "./" do not each need a case. The CLI sanitises the
name it receives regardless: a client that builds a local path out of a
remote string should not depend on the remote end having sanitised it,
and this CLI talks to whatever instance it is pointed at.

Tested with "..", "...", "./" and "a/b", with an ordinary name as the
premise leg. Restoring the old narrow guard fails it.

## Two tables for one relationship

The CLI kept its own MIME-to-extension table and it had drifted: images
and video but not gzip, tar, XML, YAML, TOML, HTML, JavaScript or
several documents the server has always allowed. So the extension
fallback added in round 24 silently did nothing for exactly the types
whose viewers most depend on it.

The CLI now delegates to attachments.ExtensionForMIME, and the second
table is gone. Measured after: gzip .gz, tar .tar, html .html, js .js,
pdf .pdf.

The reverse map needs one choice per type where several extensions
share one, and those preferences are asserted to name types the forward
map actually uses - because the first version listed "text/yaml", which
this map does not use (it says application/yaml), so that preference
could never fire. Same class as the alphanumeric guard removed in the
previous commit, caught the same way.

Also recorded against myself: I destroyed both new functions mid-edit by
running git checkout on a file with uncommitted work, to "revert an
approach". That is a documented trap I have hit before and had written
down. The committed function survived; the uncommitted ones did not.

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

* test(server): make the body-reader scan scope-aware, wrong in both directions before (BUG-2803)

Codex round 26, finding 4. The scan used ONE flat name-set per top-level
function, which is wrong in both directions at once:

  - a function literal inside a handler was scanned with the OUTER
    function's request names, so an unrelated inner variable that
    happened to be called r was FALSELY flagged;
  - a request arriving only as a function literal's own parameter was
    INVISIBLE, because literals were never given names of their own.

A false flag in this test is not harmless. The only way to green is to
add the file to the accounted list, and an accounting entry HIDES every
future reader in that file - so a false positive here converts directly
into a blind spot later. That is the same trap that made me abandon the
broadened regex two commits ago.

Now walks a SCOPE at a time. Each scope inherits its parent's request
names, drops any it shadows with a parameter of a different type, and
adds its own. Local aliases (req := r) are picked up as well, since that
is an ordinary thing for a handler to do and the alias reads the same
body.

Three controls, run rather than argued:

  closure parameter reader   -> FLAGGED
  local alias reader         -> FLAGGED
  shadowed inner variable    -> not flagged

The first two were invisible to the previous scanner, which never gave
literals their own names, and the third is the false positive it
produced - both by reading the code this replaces.

Limits restated honestly rather than left as they were, since two of
them are now closed. Still invisible: a request in a struct field, one
from a context, and one whose type reaches http.Request through an alias
or embedded field. This matches the literal spelling rather than
resolving types; closing those means the type checker, not the parser.

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

* fix: stop the reverse MIME map emitting BLOCKED extensions, and close four instrument gaps (BUG-2803)

Codex round 27 returned "do not merge yet" with three P1s. All of them
are mine, from the previous two commits.

## The reverse map turned a refusal list into a source of extensions

extMIMEMap is the FORWARD table used to REFUSE uploads - it deliberately
lists .svg, .exe, .com so those extensions can be recognised and
rejected. Reversing it wholesale meant ExtensionForMIME("image/svg+xml")
answered ".svg", where the old CLI table answered nothing, and
 names a local file with that.

So I closed an SVG door two commits ago and reopened one through the
MIME helper. Blocked types now get no reverse mapping at all, and the
test asserts it with a premise leg (the map must CONTAIN a blocked type,
or the assertion never runs). Removing the exclusion fails naming .svg,
.com and .msi.

## The oracle was closer, not identical

Production descends only into a JSON DOCUMENT - a string whose trimmed
form starts with { or [. The oracle unmarshalled any valid JSON, so a
SCALAR under a listed key made it answer true where production answers
false. Closer to production is not a usable oracle; only identical is.
Aligned, and the scalar case is in the corpus.

## The scan was still not scope-aware, and could now MISS a reader

A nested block shared the enclosing name-set, so
{ r := &http.Response{}; r.Body.Read(nil) } was FALSELY flagged. And the
shadowing rule deleted a name rebound to http.Request BY VALUE - which
still shares the Body, since it is an interface holding the same reader
- so that read became invisible. Blocks are now their own scope and a
value request counts.

## The controls I claimed were not in the suite

Round 27 was right: I had run them as throwaway probes and deleted them,
so nothing held the scanner to them. The scanner is now a package-level
helper and TestBodyReaderScanDiscriminates drives it over ten synthetic
files - six that must be detected (plain, unconventional name, closure
parameter, alias, value copy, form reader) and four that must not
(no request, shadowed by a closure parameter, rebound in a nested block,
mentioned only in a comment).

## And an over-refusal of my own making

The filename guard rejected any dot-only name and anything containing a
separator. Only "." and ".." are path components; "..." is an ordinary
POSIX filename, and filepath.Base has already reduced "a/b" to "b", so
the separator test was dead on this platform and removed rather than
left looking load-bearing. Preservation controls now pin that
legitimate names survive.

Also removed: an unused id parameter on safeLocalFilename.

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

* test(server): only a DEFINE can rebind a name, and cover the idiomatic reassignment (BUG-2803)

Found by probing my own previous commit rather than by a review round -
the first time in this sequence I have caught the adjacent breakage
before the next round did.

The scope rules deleted a request name on ANY assignment whose right
side was not a request identifier. That is wrong in the dangerous
direction, and it fires on the most idiomatic line in Go HTTP code:

    r = r.WithContext(ctx)
    io.ReadAll(r.Body)      // <- invisible to the scan

WithContext is a call, so the name was dropped and every later read went
unseen. Measured before the fix: MISSED.

The correct rule is type-sound. Go is statically typed, so a plain
cannot change a variable's type: if it held a request before, it holds
one after. Only a DEFINE introduces a new binding that can be something
else. So the delete is now gated on token.DEFINE, which is both more
correct and simpler than what it replaces.

Three controls added, and the two that would have caught this are the
ones I had not written: a WithContext reassignment, and readers inside
an if body and a for body - the last two because making every nested
block its own scope is exactly the kind of change that could have
started missing them. Thirteen controls now, six negative.

Reverting to delete-on-any-assignment compiles and fails the
WithContext leg.

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

* fix: make the body-reader scan conservative by design, and reduce filenames cross-platform (BUG-2803)

Codex round 28. Two findings, and the first is the fifth consecutive
round to find a FALSE NEGATIVE in the same instrument.

## Stop modelling scopes; change the error direction instead

Rounds 24 through 28 each found another way the scope-modelling scan
missed a real reader: a value-copied request, a plain
r = r.WithContext(ctx), a mixed r, ok := ... that reuses an existing
variable, and if/for/switch initialisers and case clauses whose scopes
it did not model. Each fix closed one case and left another. That is a
design telling me something, not a run of bad luck.

The two error directions are not symmetric here. A false NEGATIVE hides
a body reader, which is the entire thing this test exists to prevent. A
false POSITIVE costs one human review and an accounting entry with a
reason attached. So the scanner now OVER-APPROXIMATES on purpose: any
name bound to an http.Request anywhere in the file counts for the whole
file, aliases are followed to a fixed point, and names are never
un-bound. Every scope-shaped false negative becomes structurally
impossible.

The cost is real and is now asserted rather than discovered: two
controls that previously expected "not flagged" - a name shadowed by a
closure parameter, and one rebound in a nested block - now assert
CONSERVATIVELY FLAGGED, so the bias is on the record. Three of round
28's named misses are added as controls and pass: mixed short
declaration, switch case, if-initialiser shadow. Sixteen controls, and
the accounting test still passes against the real package - so the
over-approximation costs nothing today.

Exactness needs go/types with a real package load, which is a bigger
instrument than this test warrants. The comment says so, and names the
signal that would justify building it: an accounted entry whose reason
is "the scan over-flagged".

## A filename safe on this OS is not safe on the consumer's

filepath.Base is platform-specific, so on Unix it leaves a backslash
alone - and the stored name is consumed cross-platform. A Windows client
joining a stored "..\evil.png" onto a directory traverses upward.

Reduced to the leaf under BOTH separator conventions. This normalises
rather than refuses, which is less lossy than replacing the whole name
and keeps round 27's point that a backslash is legitimate on Unix.
Removing the reduction compiles and fails the new test.

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

* test(server): count readers per accounted file, add two missing reader methods, correct a false reason (BUG-2803)

Codex round 29, and its central point was aimed at my REASONING, not my
code. It was right.

## The over-approximation argument was wrong for per-file accounting

I justified a deliberately conservative scanner by saying a false
positive costs one review and one accounting entry. That is not what it
costs. Once a file is listed, a NEW reader added to it is covered by the
existing entry and the test stays green - so a false positive does not
cost a review, it permanently blinds the list for that file. My own
comment already recorded that hazard two commits earlier, and I argued
past it anyway.

The fix is to make the entry carry a COUNT of reader expressions rather
than a yes/no. Adding a reader to an accounted file now changes the
number and fails, so the entry must be re-read and its reason
re-justified. It churns exactly when a body reader is added or removed,
which is when a human should look.

Demonstrated: inserting r.PostFormValue into handlers_tokens.go - an
already-accounted file - is FLAGGED. Before this it was absorbed
silently.

The conservative bias stays, because the false-negative classes it
eliminates are real and the count now removes the reason it was
expensive.

## Two real reader methods were missing

MultipartReader STREAMS the body and FormFile triggers multipart parsing
of it. Neither was in the selector list, and FormFile is used in
production in handlers_attachments.go - so the list was incomplete
against code that exists, not hypothetically.

## A reason in the list was simply false

handlers_tokens.go was accounted as "a nil/ContentLength check only - it
never reads the body". It guards on those and then calls decodeJSON. A
wrong reason is the same defect as a missing entry: both let a reader
pass as reviewed.

## And I guessed the counts

I wrote plausible numbers for the per-file counts and every one was
wrong; the test reported the real ones on its first run. Same habit this
branch keeps catching - a figure written from expectation reads exactly
like a figure that was counted. They are measured now and the comment
says so.

Also recorded: my first verification script failed to apply its mutation
and still printed a verdict, which I nearly banked. It now aborts unless
the mutation is present in the file.

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

* fix(attachments,cli): close the closing round's two view defects, correct two overclaiming comments (BUG-2803)

The closing enumeration (successor seat, per the lead's convergence ruling)
returned two real attachment-view defects and two comments claiming more than
their code delivers. Fixed here; the round's two design-scale findings are
filed instead (BUG-2820 scanner precision via go/types, BUG-2822
Windows-unstorable filename forms).

- Reverse MIME map: four ALLOWED spellings (text/xml, text/yaml,
  application/javascript, audio/webm) had no reverse extension because no
  extMIMEMap entry uses them as its value — `pad attachment view` wrote an
  extensionless temp file for exactly the types the delegation was built to
  fix. Population measured against the whole allowlist: these four, no more.
  An alias table closes them; TestEveryAllowedMIMEHasAnExtension asserts the
  class property over the allowlist (a future allowlist entry with no reverse
  extension fails), plus alias hygiene (allowed keys only, no forward-derived
  collisions, alias extensions must map to ALLOWED types so the table can
  never mint a refused extension). Mutation-verified: removing the alias
  application fails the test on all four types.

- safeLocalFilename: a trailing dot survived every check and
  filepath.Ext("photo.") is "." — non-empty — so the MIME-extension fallback
  never fired and the temp file dispatched on no extension. Trailing dots are
  now stripped (cannot empty the name; dots-only names already returned
  early). The CLI guard also gains its first direct tests, including the
  backslash and traversal refusals that previously rode untested.
  Mutation-verified: removing the TrimRight fails both trailing-dot cases.

- Two comment corrections, same defect class the accounting list itself
  names (a wrong reason reads as review): the handlers_cloud.go entry said
  bodyHasCloudSecret "restores" the body — it restores the first 64 KiB and
  drops the tail, a bound that file documents; and the accounting test's
  header said its scan "cannot be spelled around" while its own KNOWN LIMITS
  block lists the spellings that get around it (struct field, context value,
  type alias). The header now matches the limits block.

* fix(server,attachments,cli): close closing-round-2's scanner blind spot and four stale comments (BUG-2803)

Closing round 2 (successor seat) found no product defects; all four findings
were in instruments and comments. Each verified against the code, then fixed:

- The alias fixed-point resolved only identifier RHS (`req := r`), so a
  dereferenced copy (`c := *r; io.ReadAll(c.Body)`) was an invisible body
  reader — and unlike the disclosed type-level classes, this one was not in
  the KNOWN LIMITS block. The copy shares the Body (an interface holding the
  same reader). StarExpr operands now join the alias set; a new control pins
  the case. Mutation-verified: reverting the StarExpr handling fails the
  control. The type-level classes (struct field, context value, type alias)
  remain disclosed and are BUG-2820's territory.

- The KNOWN LIMITS block said shadowed request names are "correctly
  ignored" while the controls deliberately assert they are conservatively
  OVER-FLAGGED — stale prose from the scope-aware era, falsified by the
  round-28 conservative flip that never touched those lines.

- The reverse-map stability loop compared only PREFERRED entries across
  rebuilds; it now compares the entire map (sizes and every mapping) against
  the first build. Boundary stated honestly: every multi-spelling type today
  is preference-pinned, so the full-map comparison discriminates only when a
  future non-preferred multi-spelling entry appears — that future entry is
  what it guards.

- Three orphaned/wrong comments: a `mimeForExt` doc block glued above
  ExtensionForMIME (the function it described is gone); the old hardcoded
  extension-table doc glued above safeLocalFilename (falsified by the
  delegation it predates); and two "120 chars" claims where the cap is 120
  BYTES rune-safe via truncateBindableText — the consent form's
  maxlength=120 counts characters, so a multibyte name passes the client
  and is still truncated server-side, which is now what the comments say.

* fix(cli,server,docs): close the attachment-view path escape, and closing-round-3's instrument and prose findings (BUG-2803)

Closing round 3 found the branch's first product defect since round 17, in
BRANCH-ADJACENT code the round-24 fallback extension work made reachable: the
`pad attachment view` id fallback joined the RAW id onto its temp dir, and the
client sent the id into the URL path UNESCAPED. An id is a CLI argument, but
the documented agent flow harvests it from item content ("pad-attachment:"
refs other workspace members write), so a traversal-shaped "id" could
re-route the HEAD/GET to a different endpoint whose 200 then vouched for it,
and the write escaped the temp dir. Both halves fixed and both
mutation-verified through a new command-level test: reverting the fallback
sanitize demonstrably wrote OUTSIDE the sandboxed TMPDIR; reverting the
PathEscape put a raw "../../" on the recorded wire.

- internal/cli: url.PathEscape(attachmentID) at both id-bearing client sites
  (HeadAttachment, DownloadAttachment — the enumerated population).
- cmd/pad: the id fallback runs through safeLocalFilename, generic
  "attachment" when nothing survives; view's long help no longer claims the
  filename is used "without rewriting the extension" — it describes the
  reduction and the MIME-extension append, and says why the CLI is stricter
  than the server (the name is written to YOUR filesystem).
- cmd/pad: attachmentViewCmd gets its first command-level test (CONVE-19 —
  the helper tests vouched for the component, not its wiring): disposition
  name, extensionless+MIME append, id fallback, traversal containment with a
  wire-escaping control, generic fallback.

Instrument and prose findings, each verified before fixing:

- The KNOWN LIMITS disclosure now names the ordinary alias forms the
  fixed-point does not walk (var-spec, call-derived, named results, range
  bindings) — they were in BUG-2820's filing but not in the in-file
  disclosure, which is what let the round read them as unfiled. The scanner
  itself deliberately does NOT grow another parser patch; go/types is the
  filed fix.
- TestTextSafeHelpersAreUsedAtEveryCallSite pins EXACT occurrence counts
  (measured: 1 declaration + 4 call sites each) instead of a >=4 floor a
  removed call site could hide under.
- middleware_mcp_audit: two stacked comment copies rested non-forgeability
  on "real names do not begin with (" — the exact reasoning round 25
  retired; the const doc now points at auditLabel's namespace-reservation
  rule, which is what actually makes the marker non-forgeable.
- docs/backup.md said repair is needed before "the export or migration" goes
  through, contradicting its own "exports fine" three paragraphs up — it is
  the IMPORT or migration that fails; the export succeeds either way.

* fix(server,cli): decode chunked watch bodies, refuse dot-segment attachment ids, correct two texts (BUG-2803)

Closing round 4 found one PRE-EXISTING product defect and one residue of the
round-3 fix, plus two wrong texts. Each verified before fixing:

- Watch creation gated its body decode on `ContentLength > 0`, so a CHUNKED
  request (ContentLength == -1) had its body silently DROPPED — the caller's
  predicate ignored, an unconditional watch created, 200 returned. The
  population of ContentLength gates in the package is exactly two:
  handlers_tokens.go already used the `!= 0` form, watches now matches it,
  with io.EOF tolerated so the documented no-body-is-valid contract holds
  for an empty chunked body too. Three handler-level tests discriminate the
  cases; the mutation (condition back to `> 0`) fails the two it should and
  passes the empty-body control. The accounting instrument then flagged the
  new `r.Body != nil` reference in the file — its exact job — and the file
  is now accounted with a measured reader count of 1.

- url.PathEscape leaves exact "." and ".." UNCHANGED, so those two ids still
  reached the wire as live dot segments for a proxy or server to normalize —
  the escaping added in round 3 did not cover them. Both id-bearing client
  sites now share attachmentIDPathSegment, which refuses exactly those two
  values before any request (a real id is a UUID; the refusal cannot fire on
  one). Mutation-verified: removing the refusal fails the new subtest, which
  also asserts zero requests reach a recording stub.

- The artifact rejection text said "NUL byte"; the same refusal fires for a
  NUL manufactured by a YAML escape during parsing, where no raw NUL byte
  exists — now "NUL character", in the handler message and the error var.

- A test comment claimed the User-Agent reaches sessions.user_agent as
  text; sessions store only ua_hash, as the accounting list's own exemption
  states two hundred lines up. The sentence now agrees with it.

* fix(attachments,server): remove a can't-fire MIME preference and a stale filename-guard sentence (BUG-2803)

Closing round 5 is down to two P3 comment defects; both verified and fixed:

- preferredExtensions "preferred" .md over a .markdown that has never been
  in the forward map — a line that cannot fire, the exact class this
  branch's own instruments hunt (the alphanumeric guard, the text/yaml
  preference, the charset loop). Entry removed; shortest-wins picks .md as
  the only candidate, unchanged. The preference-hygiene test now asserts
  every entry has a real competitor (>= 2 forward-map spellings), and the
  counterfactual — re-adding the entry — fails it.

- The upload filename guard still carried round 26's "checking the trimmed
  form rather than listing spellings" sentence directly above round 27's
  code that does the opposite (exact "." / ".." comparisons, longer dot
  runs deliberately preserved). The stale layer is gone; the surviving
  paragraph already records why.

* fix(server,docs): drop a dead test fixture, stop claiming the failing row is named (BUG-2803)

Closing round 6 returned one P3 — TestDecodeJSONTrimsOnlyJSONWhitespace
booted a full testServer it never used (`_ = srv`), dressing a direct
decodeJSON test in router coverage it does not have. Removed.

Its enumeration also re-read docs/backup.md against the code: "the failing
row is named in the error" is true of neither leg — the import answers 400
naming the RULE it refused on (the NUL check is body-wide and knows no row),
and `pad db migrate-to-pg` reports which WORKSPACE's copy failed. The doc
now says exactly that, and that locating the value is manual until
BUG-2810's preflight lands.

* fix(server,cli): retire a stale byte-search claim, close two instrument gaps from closing round 7 (BUG-2803)

Round 7 found no production defects; three instrument/comment findings:

- artifactIsBindableText's doc comment still asserted the round-8 byte-search
  approach and that "the ambiguity cannot arise here" — directly above the
  round-9 body comment recording that assertion as simply wrong and doing the
  round-trip walk instead. The doc paragraph now describes the round trip
  and points at the body's history.

- TestBodyReaderScanDiscriminates listed MultipartReader and FormFile in the
  scanner's selector set but had no control for either, so their removal
  from that list was undetectable. Two controls added.

- The attachment-view test proved nothing about the MIME delegation: every
  case used image/png, which the OLD hand-rolled table also knew, so a stale
  local table passed. Two cases added — application/gzip (a type round 26
  found missing from that table) must gain .gz, and blocked image/svg+xml
  must gain nothing. Mutation-verified: a stale-table mutant that answers
  only for png fails the gzip case. (First mutant attempt didn't build —
  unused import — and was not counted as a detection.)

The per-file same-count substitution gap round 7 restated is declined as
filed, not fixed: BUG-2820's filing already specifies per-call-site
accounting via go/types as the fix that retires the per-file count
workaround; the KNOWN LIMITS closing line now carries that ref.

* fix(attachments,server): sweep two pre-BUG-2413 disposition comments, pin the manifest refusal to 400 (BUG-2803)

Closing round 8 found no production defects; two evidence findings, verified
then fixed:

- Two comments still described the PRE-BUG-2413 disposition policy: the
  RenderChip mode doc said the HTTP layer serves every chip inline, and the
  read-path doc derived Content-Disposition from RenderMode. The live policy
  is the explicit fail-closed ServeInline allowlist — most chip types are
  served as "attachment". Both now say so and record the history.

- TestImportBundle_RefusesNULInManifest accepted any status >= 400, so the
  documented 400 could decay into a 500 unnoticed. Pinned to
  http.StatusBadRequest.
2026-08-30 21:30:19 -04:00
xarmian 31c8d426dd fix(web): suppress notes/decision change pills in the activity feed (BUG-2628) (#1211)
* fix(web): suppress notes/decision change pills in the activity feed (BUG-2628)

Dave's ruling, option 3. Implementation notes and decision-log entries have
had their own timeline cards since BUG-2301, so a change pill for them can
only restate the card above it. On items whose notes predate the write-time
summarizer, it restates it badly: the frozen activity metadata holds the
whole notes array as a Go map literal and the pill renders as a wall of text
that dwarfs every real change on the card.

Suppressed at RENDER time and deliberately not at write time. The activity
row legitimately records THAT notes changed and that belongs in the audit
trail; what the ruling removes is showing it as a pill. Dropping it from the
record would be option 2 — rejected in the filing for destroying the
original — wearing a different hat. One filter in parseFieldChanges covers
both surfaces that render pills (the timeline card and the activity page).

THE FIELD-NAME FILTER ALONE DOES NOT FIX THE REPORTED SYMPTOM, which I found
by measuring against the 97 legacy rows in the live database rather than
assuming the obvious fix worked:

    today                      pills=198  longest=2952  over-200-chars=77
    suppress the two names     pills=121  longest=2952  over-200-chars=1
    + field-key guard          pills=120  longest=  15  over-200-chars=0

The parser splits on ";" and a legacy blob contains semicolons, so a
fragment of a note's own prose parses as a change whose "field name" is a
paragraph of markdown — and that fragment is the LONGEST pill on the worst
row. Suppressing by field name cannot reach it. Requiring a field name to
look like one (lowercase identifier, which is what the server emits: schema
keys plus title/role/assigned) does.

The guard cannot refuse a legitimate pill: across 4000+ current-format rows
carrying 4329 pills, it drops zero. That control is what distinguishes it
from a length heuristic.

Also corrected a pre-existing comment on the function, which claimed
segments without a "from → to" transition are dropped. They are not:
"field: → value" splits into ["", "value"], two parts, and is kept with an
empty `from`. What is actually dropped is a segment with no arrow, or one
with more than one. I only noticed because the false comment made me write a
test expectation that failed — worth fixing where the next reader will hit it.

Mutation matrix, 4: dropping the suppression set, dropping the key guard,
suppressing only implementation_notes, and loosening the key guard to accept
anything — each detected, and the two rules die on different tests.

Gates: vitest 1889 passed (111 files), svelte-check 0 errors, vite build
clean. No Go changes.

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

* fix(web): finish the suppression on the surfaces that render raw (BUG-2628)

Codex round 1. Two findings, both mine, and the first meant the previous
commit did not fix the bug on the surface the ruling was about.

**The audit page renders the RAW change string when the parsed list is
empty.** Suppressing every pill on a legacy row therefore made the wall of
Go-map text REAPPEAR there — my change made that surface worse, not better,
for exactly the rows the bug is about. The dashboard is simpler and was
never covered: it renders `meta.changes` verbatim with no parsing at all.

So the suppression has to be a property of the STRING, not of the pill list.
`formatChangesForDisplay` drops segments on the same two rules and keeps
every survivor verbatim — including ones `parseFieldChanges` will not turn
into a pill, such as a value containing more than one arrow. That is what a
fallback is for, and it keeps working.

**The key guard was too strict, and my justification for it was too strong.**
It was `^[a-z][a-z0-9_]*$`, reasoned from "the server emits schema keys plus
title/role/assigned, all lowercase". Nothing constrains a collection's field
keys to lowercase — handlers_collections.go compares them with a plain `==`,
no case folding — so `Status` and `resolution-v2` are legal keys whose pills
that pattern would have dropped silently.

Replaced with a STRUCTURAL test: non-empty, no whitespace, length <= 64.
That is what actually separates a key from the prose fragment the guard
exists to reject.

The measurement I had could not tell the two guards apart, and the reason is
worth keeping: all 72 field keys in the live database satisfy both forms, so
the control leg showed zero legitimate pills dropped for BOTH. **A control
showing a guard refuses nothing HERE is not evidence that it cannot refuse
something legitimate** — the same partial-verification shape I have hit
before, this time in a control I built myself and trusted. Measured both on
the same data: identical on the legacy rows (longest surviving pill 15
chars, none over 200) and identical on 6000+ current rows (6385 pills kept,
zero dropped). Same effect, strictly smaller risk.

Mutation matrix, 6: dropping suppression or the key guard in either function,
loosening the guard to accept whitespace, and suppressing only one of the two
fields — each detected, each by the test aimed at it.

Gates: vitest 1894 passed (111 files), svelte-check 0 errors.

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

* docs(web): record the enumerated scope of the suppression (BUG-2628)

Codex round 2, asked to ENUMERATE every surface that renders an activity's
change string rather than to spot-check the two I had just fixed. It
returned the full inventory, and the result splits cleanly in two.

Deliberately unsanitized, now stated in the code: the REST endpoints,
`--format json`, the bootstrap payload and the MCP tools all return the
metadata verbatim. That is the design, not a gap — the row records THAT
notes changed, the ruling was about not SHOWING it as a pill, and a client
asking for the raw record must still get it. Sanitizing the wire would be
the destroy-the-original option the filing rejected, arriving by a
different route.

Not covered and filed as BUG-2789 rather than silently pulled in: the CLI
(`pad project activity` in both output modes, `pad workspace audit-log`)
prints the string with no parsing, and the admin console audit log renders
it verbatim through a generic metadata fallback. The CLI needs a Go twin of
formatChangesForDisplay — the filing points at the AgentNameFromMetadata /
agentNameOf precedent and warns that Go's unicode.IsSpace and JS's \\s
disagree in both directions, which matters because the guard is a
whitespace test. The admin audit log may want the OPPOSITE answer, since a
forensic tool showing less than the record contains is arguably the wrong
fix; bounding the rendered length would be the alternative. Both are
product calls this bug's ruling does not settle.

No behaviour change in this commit.

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

* docs+test: state the guard's real limits instead of overclaiming (BUG-2628)

Codex round 3. Three findings, no redesign — all three are places where my
claims were stronger than what the code does, and two of them are now
pinned as tests rather than corrected in prose and left to rot.

**The guard is a heuristic and both error directions are real.** I wrote
that a structural test "is what actually separates a key from prose". It
is not, and nothing could be: the server joins with "; " and the consumer
splits on ";", so a value containing a semicolon is indistinguishable from
two changes. Concretely, a fragment whose prose before the colon is a
single word still produces a pill — `…[map[details:Root; foo: a → b]]`
yields a stray `foo`. And a legitimate key with a space, or over 64
characters, is droppable; nothing validates key shape on collection create.

What the guard actually delivers is the measured claim, and that one
stands: across the 97 legacy rows the longest surviving pill goes from
2952 characters to 15, and none exceeds 200. It removes the WALL. It does
not promise zero fragments, and the comment now says so.

**"Preserved VERBATIM" was false.** formatChangesForDisplay trims each
survivor and rejoins with a canonical "; ", so boundary whitespace and
separator spacing are normalized; and a legitimate value containing a
semicolon loses its tail, because the orphaned fragment fails the field-key
rule. Now described as keeping CONTENT, with both normalizations named.

Two new tests pin the limits so they are known rather than latent: the
single-word fragment that still produces a pill (asserting the survivor is
small, which is the property that matters), and the semicolon-bearing value
that loses its tail. A limit with a test is a decision; a limit in a comment
is a hope.

One test renamed from "emits no pill for a legacy blob" to "removes the
wall from a legacy blob" — the old name asserted the absolute the code does
not provide.

Filed IDEA-2790 for the durable fix: emit changes as structured metadata
alongside the string, so no consumer parses a display format. It does not
help the frozen legacy rows, which is why the heuristic stays either way,
but it would shrink BUG-2789 considerably. The comments here point at it.

Gates: vitest 1897 passed (111 files), svelte-check 0 errors, vite build
clean.

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

* fix(web): the sanitizer must reject fragments, not just bad keys (BUG-2628)

Codex round 4, and it found a gap in my MEASUREMENT, not just my code.

formatChangesForDisplay filtered on the field-key rule alone, so a fragment
from inside a serialized notes array whose text before the colon is short
and unspaced — `id:note-1775153870894988317 summary:…` — posed as a key and
was kept. The audit page's fallback then rendered it: the wall, back on the
surface this function was written for.

Requiring at least one arrow drops it. Not exactly one: a value containing
"→" is a real change that parseFieldChanges declines to make a pill of, and
showing it is precisely what a fallback is for. Both directions are pinned
by different tests, so neither rule stands in for the other.

Measured on the 97 legacy rows: the longest sanitized output falls from 285
characters to 54. On 6000+ current-format rows the new rule drops exactly
ONE segment — `title: Strip '…' from README`, itself the orphaned tail of a
title change that fragmented on a semicolon in the title, so dropping it is
correct rather than a cost.

THE PART WORTH KEEPING: every figure I had quoted for this fix — 2952 to
15, 77 oversized pills to zero — was measured on parseFieldChanges. I never
measured formatChangesForDisplay against the corpus at all, and reported
the pill numbers as though they covered the fix. They covered half of it,
and the unmeasured half was leaking 285 characters of blob to the surface
the ruling was actually about. Two functions, one measurement, and the
write-up did not distinguish them.

That is the same shape as the guard-control error from round 1 — a
measurement that proves something narrower than the sentence it is attached
to — hit twice in one unit, in the same direction both times.

Gates: vitest 1898 passed (111 files), svelte-check 0 errors.

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

* docs(web): record every way a legacy blob can still reach a reader (BUG-2628)

Codex round 5, asked to enumerate rather than spot-check: for the three
surfaces this PR covers, list every distinct path by which a legacy notes
blob could still reach a user. It returned the complete table and found no
path the previous rounds had missed.

Three handled, two open. The two open rows — a key-shaped fragment with
exactly one arrow, and one with several — are the format ambiguity itself
and are not closable by any shape test, since the server joins with "; "
and the consumer splits on ";". Both already have tests, so they are known
limits rather than latent surprises, and IDEA-2790 is the fix that removes
the class rather than bounding it.

The table is in the code because the enumeration is the deliverable. A
reader asking "is this fully closed?" now gets the answer and its shape
without re-deriving it, and the honest answer is BOUNDED, not closed: on
the 97 legacy rows the longest surviving pill is 15 characters and the
longest sanitized fallback string is 54, from 2952 and 285.

Findings by round: 2, 2, 3, 1, 0-new. No behaviour change in this commit.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-26 05:26:51 -04:00
xarmian f465d4c51e fix(server): a malformed before_id is a 400, not a 500 (BUG-2774) (#1205)
* fix(server): a malformed before_id is a 400, not a 500 (BUG-2774)

before_id went from the query string into the cursor predicate unchecked.
Postgres refuses a text parameter that is not valid UTF-8 or that carries a
NUL (SQLSTATE 22021/22P05), so the store call errored and the handler answered
500 — the server announcing its own failure for a client's bad input, and
SQLSTATE noise in the logs for an input problem. SQLite accepts the same bytes
and matches nothing, so the identical request was a 200 there; the failure mode
diverged by dialect from one line of unvalidated input.

validCursorID rejects exactly what the DATABASE rejects rather than what an id
should look like, and deliberately carries NO length or format bound: the
structured kinds' ids come from the item's own fields blob, nothing validates
them on write, and an imported artifact may carry any string — so a cap could
only ever fire on a legitimate cursor, while the cost of an over-long one is a
single indexed comparison against a parameter the URL length limit already
bounds. Its comment says so, because the obvious review question is why there
isn't one.

Tests: three malformed shapes reachable from a plain URL (%FF, an embedded NUL,
invalid bytes mid-string) plus three controls — a UUID, a structured note id,
and a long non-ASCII id — without which "reject every before_id" would pass and
paging would be dead rather than honest. A store-level test pins the PREMISE
where it is real: on Postgres the query itself fails, on SQLite it succeeds and
returns nothing, and both halves are asserted in the one place that sees both,
each with a message saying what it means if the backend's behaviour has moved.

Verified on Postgres 17 (private container, not the shared port). The handler
legs fail with the validation removed.

Refs: BUG-2774

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

* fix(server): never emit a cursor the handler would refuse (codex round 1)

The validation had a second direction I had not closed. A structured entry's id
comes from the item's fields blob, which nothing validates on write, so a JSON
\u0000 escape arrives as a real NUL on SQLite — Postgres's jsonb refuses it at
the door, which is why this is a one-backend hazard. That id became the entry's
id, the entry's id became next_before_id, and the client sending it back got a
400 from the validation this unit just added: the server handing out a cursor
it then refuses, wedging paging on that item.

Such an id now takes the positional fallback that empty and duplicate ids
already take — the id has to be usable as a CURSOR, not merely unique. One
condition on an existing branch rather than new machinery.

Its test uses two notes, one NUL-bearing and one clean, so it distinguishes
'replaced the unusable id' from 'stopped using raw ids at all'. Verified
against the unmutated branch: the NUL id is emitted verbatim and the leg fails.

Refs: BUG-2774

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

* test: assert the envelope, the emitted cursor, and split the dialect premise (codex round 3)

Three test weaknesses, all mine.

Status-only assertions: a bare 400 or the wrong code would have passed, and
clients branch on the code rather than the number. The legs now decode the
envelope and require invalid_cursor with a non-empty message.

The emitted-cursor test claimed to cover next_before_id and did not: at
limit=50 the fixture emits no cursor at all, so it only checked entry ids. A
third note and a truncating limit make the NUL-bearing one the LAST kept entry,
which is what puts it in the cursor — and the test now asserts the cursor is
valid AND that paging with it returns 200. The mutant (emitted ids unchecked)
still dies, now on the thing the test is named for.

The dialect test is CHARACTERIZATION and now says so: it passes with or without
this change, because it describes the backends rather than the handler, and
that is its job — it is the premise the 400 rests on. Its 'both halves in one
place' claim was also false in an unconfigured run, where NewPostgres skipped
the whole test and the SQLite half never ran either. Split into subtests: the
SQLite half runs everywhere, the Postgres half skips alone.

Refs: BUG-2774

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

* fix(server): a one-sided cursor is a 400, not a silent no-op (codex round 4)

before_id alone could never match anything: the id is the tie-break AT the
cursor instant, and before defaults to now+1m, which no row shares. So it was
accepted, ignored, and the caller paged from the beginning believing they had
a cursor — the accepted-and-does-nothing shape, which is the worst answer of
the three available.

The other direction stays supported deliberately, and has a control leg saying
so: before alone is the external-client case the "g" sentinel exists for, and
rejecting the pair symmetrically would break something the handler goes out of
its way to serve.

In scope for this unit rather than a separate filing because it is the same
parameter and the same class of answer — and because BUG-2765's PR body
documented "both fields or neither" as the contract, which until now nothing
enforced.

Codex's other round-4 finding — a structured entry id can collide with a
comment/activity/version id, because the dedupe map holds only structured ids —
is real, pre-existing, and a cross-source id decision rather than a validation.
Filed as BUG-2783.

Refs: BUG-2774, BUG-2783

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

* docs: the cursor contract is asymmetric, and now says so (codex round 6)

BUG-2765's prose said "both fields, or neither" in the handler doc comment and
the TS client. This unit then enforced it in one direction only — deliberately,
because `before` alone is the external-client shape the id sentinel exists to
serve, while `before_id` alone matches nothing and silently pages from the
beginning. The slogan was mine and the asymmetry is mine; leaving both in place
would have left a reader to discover the difference from a 400.

Both sites now state which one-sided form is accepted, which is refused, and
why each.

Refs: BUG-2774

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-25 19:17:49 -04:00
xarmian a2195997f7 fix(web): an entry missing from a refreshed first page is not necessarily deleted (BUG-2773) (#1203)
* fix(web): an entry missing from a refreshed first page is not necessarily deleted (BUG-2773)

The SSE refresh re-fetches the FIRST page — the newest N entries — and treated
anything previously on it and now absent as deleted. Once enough newer entries
exist, a perfectly alive entry rolls off that window, and it disappeared from
the reader's view; for anyone who had pressed Load More it vanished from the
MIDDLE of a timeline whose neighbours on both sides were still shown. A full
reload brought it back, which is the tell that this was display state and not
data.

Per the lead's ruling (option 1): deletion is inferred only for a position the
fresh page still COVERS — at or newer than its oldest entry, compared in the
same (created_at, id) space the server's cursor uses. Anything older is out of
window and left alone. An empty fresh page covers nothing and so deletes
nothing: it means every row in that window was unrenderable, not that the
history was erased.

Every test leg pairs a roll-off with a real deletion, because a fix that simply
stopped removing anything passes the roll-off half alone. The refresh helper
asserts the refresh actually FIRED — the first version of these tests waited
400ms against a 500ms debounce, and one leg passed vacuously on a refresh that
never happened.

Mutation matrix, each detected by its own leg: the old rule (3 legs); string
comparison instead of instants at the boundary (the sub-second leg); an empty
fresh page covering everything; the id tie-break dropped.

Refs: BUG-2773

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

* fix(web): a final page covers everything, and coverage replaces the first-page gate (codex round 1)

Two findings, both real.

has_more was ignored. A refresh whose page is FINAL returned the whole
history, so an entry the client holds and that page does not contain has
nothing to have rolled off into — it is gone, deleted or no longer renderable.
Coverage now extends to everything on a final page, which is also what makes an
empty FINAL page clear the view while an empty page with more behind it still
deletes nothing. Both directions have a leg; asserting either alone would let
"empty always clears" or "empty never clears" pass.

firstPageIds is retired rather than repaired. It tracked the last first-page
fetch so older-page entries would not be judged by a first-page comparison —
which coverage now does directly and better: an older-page entry sits below the
floor and is left alone, while an entry INSIDE coverage that the page does not
contain is gone regardless of which page delivered it. Keeping both would have
leaked: an entry preserved as a roll-off dropped out of the tracked set, so a
later window expanding back over it could never remove it again.

Mutation matrix, six mutations, each detected: no final-page rule (2 legs); the
old missing-means-deleted rule (3); never deleting (5); string comparison at
the boundary; the id tie-break dropped; an empty non-final page treated as
covering.

Refs: BUG-2773

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

* fix(web): fence overlapping SSE refreshes, and state what inference cannot do (codex round 2)

Only the newest dispatched refresh may write. The item/workspace check catches
a switch but not two refreshes of the SAME item in flight at once — the retry
path fires 2s after a failure while a newly debounced one is already running —
and an older response landing last re-adds an entry the newer one removed or
removes one it added. A monotonic seq, a plain let rather than $state because
it is read and written inside the refresh (CONVE-1688). Its test holds both in
flight and resolves them out of order; it fails with the fence removed.

Two other round-2 findings are DOCUMENTED, not fixed, because they are limits
of inferring deletions from a first-page comparison rather than defects of this
change, and the lead ruled the event-based alternative out of scope for this
unit:

  - an entry deleted below a non-final page's floor is never inspected again
    and stays until a reload. The old rule removed it — by removing every
    rolled-off entry with it, which is the bug being fixed. Strictly better,
    not complete.
  - whether a row renders depends on the window it was fetched in, since the
    cross-source drops need both rows in one fetch, so an entry that rendered
    on an older page can be absent-and-covered here. That matches what a fresh
    load shows, which is the ceiling for any first-page comparison.

The fourth finding — the refresh not adopting has_more/next_cursor — is
declined a second time, on the same grounds and now with its concrete harm
checked: after paging to the end the reader already HOLDS everything behind
page one, so a fresh has_more=true strands nothing.

Refs: BUG-2773

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

* test+docs: a leg that fails against the OLD rule, and three claims corrected (codex round 3)

The two final-page legs passed against main: the old gate removed any entry
missing from a refreshed first page unconditionally, so they discriminated
against an intermediate version of this fix and not against the behaviour it
replaces. Added the leg that does — an entry loaded via LOAD MORE, which the
old gate preserved unconditionally because it had never been on a first page,
and which a final refreshed page must now remove. Verified by restoring the
pre-fix rule: that leg fails, along with three others.

Two comments were false and are corrected rather than softened:

- Sub-second timestamps are NOT reachable from this server. The store writes
  RFC3339 seconds and the handler truncates the structured kinds' hand-written
  ones to match (handlers_timeline.go's stamp()). The instant-vs-string
  comparison is a guard on what the ordering MEANS, not a live scenario, and
  both the code and the test that pins it now say so. The claim came from the
  BUG-2765 unit and was wrong there too.
- Comment-linked activities are excluded by the store's SQL whether or not the
  comment is in the window, so they are not an example of window-dependent
  rendering. The version-coincidence suppression is; the note names that one
  now.

Refs: BUG-2773

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

* fix(web): a stale Load More page cannot resurrect a deleted entry (codex round 4)

A paging request can be in flight while a refresh removes an entry as deleted.
Its page is older than the refresh and still carries that row, so appending it
verbatim put the entry back on screen — visibly undoing a deletion the reader
had already seen happen.

The refresh now records what it removed, and Load More filters its page through
those tombstones. Chosen over discarding the whole stale page (the reader's
click would do nothing) and over a generation fence (same). The set is cleared
whenever loadTimeline resets the view, so it is bounded by one mount's
deletions.

Its test holds the paging response, deletes the entry via a refresh in between,
then lands the stale page and asserts both halves: the deleted row does not
come back AND the rest of the page still does — without the second, discarding
everything would pass. Fails with the tombstone check removed.

Refs: BUG-2773

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

* fix(web): discard a stale Load More page instead of tombstoning what it may resurrect (codex round 5)

Round 4's tombstone set answered the right question with the wrong machinery,
and round 5 found the bill: it grew for the life of a mount with no prune, its
clear point raced older paging continuations (a same-item mutation calls
loadTimeline, clears the set, and a stale page can then resurrect a deleted
entry anyway), and it would suppress a same-id structured entry legitimately
rewritten. Each of those is fixable; together they are a sign the mechanism was
too clever for the race it guards.

Replaced with the fence already used for overlapping refreshes: loadMore
captures the refresh sequence before its await and discards the response if a
refresh applied in between. One integer, no growth, no lifetime, nothing to
clear at a switch. The page is dropped WHOLE rather than filtered — it was
assembled before the deletion and nothing in it reflects the current view — and
the cursor is untouched, so Load More is still offered and the next click
fetches the same page against the current state.

The test now asserts all three halves: the deleted entry does not come back,
the rest of the stale page does not land either, and the button is still there.
Without the third that would read as a silent drop rather than a deliberate
one. Fails with the fence removed.

Refs: BUG-2773

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

* fix(web): one view generation for reloads as well as refreshes (codex round 6)

The fence only counted SSE refreshes, so the path that most obviously replaces
the view did not advance it: a local comment delete calls loadTimeline, page 1
comes back without the entry, and a Load More page in flight from before that
re-adds it. Identity and sequence both pass, because neither noticed.

One counter now, incremented by every reload and every refresh, captured by
every continuation that writes entries — including loadTimeline itself, which
could otherwise overwrite a newer view with an older page-1 response.
"The view was replaced" is the same fact whichever path replaced it.

Tested through the harness-reachable form: a Load More page held across a
switch AWAY and BACK. By the time it resolves the identity check passes again —
same item — and only the generation can tell that the view it was fetched
against has been replaced twice. Fails with the reload's increment removed.

The local-delete path itself is not directly driven: it needs the comment
controls, which need mutationsEnabled plus canEditItem plus the confirm flow.
It goes through the same single increment as the switch case, but that is an
argument, not a test, and this note is here so nobody reads the coverage as
wider than it is.

Refs: BUG-2773

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

* fix(web): count view replacements that LANDED, and fence the cleanup paths (codex round 7)

Two regressions my own fence introduced.

The generation advanced at DISPATCH, so a refresh that then FAILED still
invalidated an in-flight reload: the reload's good response was discarded and
the view stayed empty until something else redrew it. It now advances where the
write lands — a request that never writes is not a replacement. Its test holds
the initial load, fails a refresh across it, and asserts the load still renders;
it fails when counting goes back to dispatches.

The catch and finally only checked identity, so a stale request's cleanup could
clear the spinner a newer one owns or restore an old error over a current load.
Both are gated on ownership now.

Ownership needed a flag rather than a bare reqGen === viewGen: the writer
advances the generation ITSELF, so after writing it reads as stale by its own
test — the first version of this blocked its own `loading = false` and left an
empty page under a permanent spinner. Caught by two existing tests failing, not
by review, which is the instrument working.

(This message is a re-write: the first one was passed through a double-quoted
shell string and the backticked span was executed and blanked — CONVE-13, the
convention that exists because of exactly this. Caught by re-reading the
artifact rather than the success line, which is the other half of it.)

Refs: BUG-2773

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

* fix(web): ticket plus high-water mark, so newest wins and a failure costs nothing (codex round 8)

One counter could not express both halves, and each single-counter version
failed a different way. Counting DISPATCHES let a refresh that then failed
invalidate an in-flight reload (round 7). Counting APPLIES let an OLDER
response landing first claim the view and lock the newer one out behind it
(round 8) — the mirror of the case the out-of-order test already covered, and
the reason that test alone was not enough.

Every view-replacing request now takes a unique increasing ticket at dispatch
and may write only if it beats the high-water mark of what has actually
written, which it then owns. Newest wins among concurrent responses, and a
request that never lands costs nothing.

Second round-8 finding, same root: an overlapped load declines to clear the
spinner once a newer write has landed, so the writer has to. Without it an
empty refresh result sat under a permanent spinner and the list never rendered.

Mutation checks, each with its anchor count asserted after two mutations
silently failed to apply and left a GREEN run that proved nothing: the refresh
not clearing the spinner fails the new spinner leg; the round-7 applied-counter
semantics fail the new newest-wins leg; the loadMore fence removed fails the
switch-away-and-back and stale-page legs.

Refs: BUG-2773

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

* fix(web): a reload clears loadingMore too (codex round 9)

loadMore's cleanup is identity-guarded, so a page resolving while the reader is
on another item never cleared the flag. Coming back found Load More permanently
disabled — a dead control, which reads as "there is nothing more" rather than
as a bug. Pre-existing (the identity guard is TASK-2112's), and one line to
close now that loadTimeline is already the single place paging state resets.

The existing switch test released its stale page AFTER returning, which is the
ordering that never exercised this; the new leg resolves it while away and
asserts the button comes back enabled. Fails with the reset removed.

Refs: BUG-2773

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

* fix(web): the coverage boundary is the server's cursor, not the oldest row returned (codex round 10)

How far back a page LOOKED and how far back it RETURNED rows are different
positions, and BUG-2765 made the first one available: next_before is where the
next page starts, so everything at or newer than it was examined. When one
source exhausts its over-fetch window while another returns an older rendered
row, the cursor sits NEWER than the page's oldest entry — and judging by the
returned floor then treats a live entry from the exhausted source, one this
page never reached, as deleted.

Using the cursor makes the rule say what it means. The returned floor stays as
the fallback for a server predating that field; it is the slightly-too-eager
version, and still narrower than the rule this unit replaces.

Two comments corrected rather than left: "the oldest entry the fresh page
reached" was the oldest RETURNED, and "has_more=false means the server returned
everything it has" ignored that rows are fetched and dropped as unrenderable —
which is precisely why absence from a final page still means gone.

The new leg builds the shape only a server can produce (cursor newer than the
oldest returned row) and asserts both sides of the boundary: the entry below
the cursor is kept, the one above it is removed. Fails with the boundary put
back to the returned floor.

Refs: BUG-2765, BUG-2773

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

* docs: three of my own claims corrected, including one a commit message overstated (codex round 12)

- The refresh's comment still said only the newest DISPATCHED refresh may
  write. Round 8 replaced that with ticket-versus-high-water, which lets an
  older response write first and be replaced by the newer one — deliberately,
  and with its own test. The comment described the scheme two rounds ago.
- The final-page test still said has_more=false means the server "returned
  everything it has". It reached the end of the rows; some were dropped as
  unrenderable on the way, which is why absence from a final page still means
  gone. My round-10 commit claimed this wording was corrected — it was, in the
  component, and not here. The claim was true of half the sites and written as
  though it covered both.
- The file header said every leg pairs a roll-off with a real deletion. That
  was true of the first leg and stopped being true as the legs accumulated; and
  the empty-final-page leg described "both refreshes" when it makes one. Both
  now say what the tests do.

Refs: BUG-2773

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

* fix(web): a superseded refresh does not retry (codex round 13)

The retry exists so a transient failure does not leave the panel quietly stale
(BUG-2508). Once a newer refresh has written the view there is nothing stale to
repair: the retry is traffic for a question already answered, and its answer
would arrive older than what is on screen. Guarded on the same high-water mark
every other write path uses.

Its test fails an older refresh after a newer one landed and asserts the
request COUNT does not move across the retry backoff — the entries look
identical either way, so the count is the only thing that distinguishes the
two behaviours. Fails with the guard removed.

Refs: BUG-2773

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-25 18:01:22 -04:00
xarmian 11f67b0a98 fix: timeline can answer has_more=true with zero entries, and the client cannot page past it (BUG-2765) (#1202)
* fix(server): timeline returns the cursor for its next page (BUG-2765)

The timeline over-fetches 3x per source and drops rows that cannot render
(read/searched actions, empty-metadata updates, activities a version or a
comment already stands for, collapsed autosave bursts), so a page can carry
fewer entries than the rows it consumed — or none, while has_more is true.

The client derived its cursor from the last RENDERED entry, which fails in two
ways. With no entries it cannot form a cursor at all, so the first page is a
dead end. With a fully-dropped window LATER in the history it re-sends the same
cursor forever: nothing is appended, the oldest entry does not move, and paging
is wedged at that position permanently. The filing named the first; the second
is the one that bites an ordinary item, since a run of read activities anywhere
in its history is enough.

Both are the same root cause — the response says WHETHER to continue and not
WHERE — so the server now returns next_before / next_before_id whenever
has_more is true:

- page truncated: the last entry KEPT, because the ones cut off must be
  re-fetched. Unchanged from what the client derived.
- window exhausted: the NEWEST tail among the sources that filled their window.
  A short source has nothing older to come back for and must not drag the
  cursor forward; resuming at the oldest tail instead would step over a newer
  source's unexamined rows, and repeats are absorbed by the client's dedup
  while gaps are not recoverable.

Progress is guaranteed because every candidate is a row this page fetched and
the store's cursor predicate is strict.

Tests: an all-dropped window returns a cursor that reaches the history behind
it; paging across a dropped MIDDLE stretch terminates and yields each
renderable entry exactly once; and a control leg pins that an untruncated page
still resumes at its last rendered entry — without it, "always resume from the
oldest row touched" passes while silently skipping what truncation cut. The
two-full-source selection rule is pinned as a unit test, because a full source
whose rows RENDER puts 3x limit entries on the page and takes the truncation
branch instead, so the handler cannot cheaply reach it.

Mutation matrix, each independently detected: cursor from rendered entries;
oldest tail instead of newest; the full-window flag ignored; no cursor at all.

Client half follows in the next commit.

Refs: BUG-2765

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

* fix(web): page the timeline with the server's cursor (BUG-2765)

Client half. The component derived its next-page cursor from the last RENDERED
entry, so a page the server had emptied by dropping rows either gave it nothing
to page from (the first page, where loadMore returned early on
entries.length === 0) or gave it the SAME cursor it already held (a later page,
where nothing was appended and the oldest entry did not move). The second case
is a permanent wedge with a live button and a running spinner.

It now pages with next_before / next_before_id when the server sends them, and
falls back to the last entry otherwise — which is exactly the old behaviour,
including its wedge, and is there only for a server that predates the field.

One press walks at most MAX_EMPTY_HOPS pages while every row keeps dropping.
That bound is UX, not correctness, and its comment says so: a single hop is
already correct now that the cursor advances; the loop exists so a user
crossing a long run of read activity sees entries appear rather than a spinner
and nothing, and it is small so a pathological item cannot turn one click into
an unbounded request fan.

Tests assert the cursors the component ASKS FOR, not only what it displays — a
component that shows the right thing by re-fetching page one forever is the
bug. Mutation matrix: ignoring the server cursor fails three of four legs (the
fallback leg survives, correctly, since that is the path it pins); a single hop
fails the advance leg; an unbounded hop count fails the bound leg.

vitest 109 files / 1862 tests pass; vite build clean; svelte-check 0 errors
(6 warnings, all pre-existing and in other files).

Refs: BUG-2765

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

* fix: cursor must clear BOTH bounds, and a later page must merge not append (codex round 1)

Two findings, both real, both consequences of the cursor itself.

P1 — truncation and an exhausted window are INDEPENDENT bounds, and the second
is not implied by the first. A source whose rows all drop contributes nothing
to the page, so the truncation cursor can sit older than that source's tail,
and every unexamined row between the two falls in a gap neither page fetches.
The cursor is now the NEWEST candidate across both reasons. Its regression puts
one renderable activity in exactly that gap: two comments forcing truncation at
limit=1, three read rows filling the activity window above them, and the row at
risk in between. Run against the previous commit it comes back 0 times — the
row is not late, it is gone.

P2 — a later page can legitimately carry entries NEWER than the oldest one
already shown, because the cursor deliberately re-covers ground when one
source's window ran out before another's. Concatenating printed those below
older entries. The client merges by (created_at desc, id desc) now, comparing
INSTANTS rather than strings: precision is not uniform — the store writes whole
seconds but a structured note can carry a sub-second timestamp — and
lexicographically "…:05.123Z" sorts before "…:05Z".

Mutation checks: truncation ignoring the exhausted candidate fails the new gap
test; concatenating instead of merging fails the new order test and nothing
else.

internal/server suite green; timeline vitest 9 files / 83 tests green.

Refs: BUG-2765

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

* fix(web): stop the hop loop when the cursor does not advance (codex round 2)

Against a server that predates next_before, the fallback re-derives the same
last entry on every hop, so a single Load More click fired five identical
requests where the pre-fix component fired one. The client cannot give an old
server a cursor it does not have — that wedge is the old behaviour and stays —
but amplifying it was new, and mine.

A cursor that did not move cannot make progress, so the loop stops on it. Its
test asserts the request COUNT, which is the only thing that distinguishes this
from the behaviour it replaces: the entries rendered are identical either way.

Refs: BUG-2765

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

* fix(web): order the SSE-refresh merge too, and share one comparator (codex round 3)

The refresh prepended its genuinely-new entries on the assumption that a fresh
first page is always newer than everything on screen. Normally it is — but a
structured note or decision carries a hand-written created_at and can arrive
backdated, and the assumption was never stated, only relied on. Both merge
points now go through one byNewestFirst, which is the server's own ordering and
compares instants rather than strings.

Two of codex's three round-3 findings are not folded in:

- The refresh not adopting the response's has_more / cursor is DECLINED, not
  missed. The refresh re-fetches the NEWEST window, which says nothing about
  where the reader's paging frontier is; the stored cursor stays valid because
  the refresh consumes no older rows, and adopting the fresh page's has_more
  after the reader has paged deeper would point the cursor back at history they
  already hold. One wasted request, absorbed by the no-advance stop, in
  exchange for a correctness claim I cannot make.
- firstPageIds treating any entry missing from a refreshed first page as
  deleted is real, pre-existing, and a semantics call about what counts as a
  deletion rather than a patch: filed as BUG-2773.

Refs: BUG-2765, BUG-2773

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

* test(server): derive the expected entry set instead of naming two ids (codex round 4)

The test claimed every renderable entry came back exactly once and checked the
two ids it had seeded. The item's own `created` activity could have vanished or
repeated underneath that claim — the same partial-verification shape as
asserting one direction and writing the symmetric conclusion.

The expectation now comes from the store: every activity on the item minus the
kinds buildTimeline drops unconditionally, compared in both directions, so a
fixture that grows a row cannot fall outside what the test says it covers.
Still fails with the cursor withheld.

Refs: BUG-2765

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

* docs: state the timeline cursor contract where consumers read it (codex round 5)

The TypeScript type documented next_before/next_before_id, but the handler's
own doc comment — what a REST consumer reads — still described before + limit
only, and the API client method said nothing. A consumer following either could
still derive a cursor from its last visible entry, which is precisely the
invalid contract this change exists to replace.

Both now state the pair, that it must be forwarded rather than re-derived, why
(dropped rows make the last entry a different position, sometimes no position),
and that the id is the tie-break among entries sharing a second — send both or
neither.

Refs: BUG-2765

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

* fix(web): clear paging state when the timeline reloads (codex round 6)

The previous item's entries stay on screen while the new one's page 1 is in
flight — the list renders on `!loading || entries.length > 0` — so Load More is
clickable during a switch, and its cursor was the OLD item's position aimed at
the NEW item. Pre-existing in shape (the pre-fix code derived the same stale
position from the same stale entries), but now it is one line to close, and the
dedicated cursor variable is mine.

loadTimeline replaces entries with page 1 when it resolves, so clearing the
cursor and has_more before the await throws away no state that would have
survived; it just stops offering paging for a position that is no longer known.

Test holds the switch's fetch unresolved and asserts no request goes out in
that window, with the button's absence as the observable. Fails with the two
lines removed.

Refs: BUG-2765

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-25 16:11:47 -04:00
xarmian 99ffad1bca feat(server): timeline comment rows carry the agent name (TASK-2760) (#1196)
* feat(server): carry the agent name onto comment rows in the timeline (TASK-2760)

An agent's comment rendered under the human's name: the name is stamped only
on the linked 'commented' activity, which the timeline suppresses because the
comment card stands in for it. The comment list queries now LEFT JOIN that
activity and surface the name as Comment.AgentName (top-level and nested
replies, on the timeline and the comments endpoint alike, through one scan
helper), mirrored onto comment-kind TimelineEntry.agent_name to match the
actor_name idiom. The web comment card renders it verbatim in an isolated
<bdi>, separate from the human author.

Store join rather than a handler-side match: the two lists are paginated
independently, so a handler join misses at page edges and reads as
intermittently-correct attribution. Metadata is parsed in Go, not SQL, to
keep the query free of a SQLite/Postgres dialect fork.

* test(store): make the activity-window premise strict, not a same-second coin flip (TASK-2760)

* fix(server): replies log + link their commented activity so the agent name reaches them (TASK-2760, codex r1)

The dedicated reply route wrote no 'commented' activity, and the activity is
the only row that carries the writing agent's name — so a reply through the
web UI rendered under a generic chip no matter what the client sent. Also
rewrites the README + SKILL.md claim that comments never show the name, moves
the reply test onto the real route, and asserts order/limit under the join.

* fix(store): exclude comment-linked activities in the timeline's activity query (TASK-2760, codex r2)

buildTimeline suppressed a comment's linked activity only when that comment
was on the same page; the two sources are paginated separately, so an
activity could slip through as a standalone 'commented' card. The query now
excludes linked rows via NOT EXISTS on idx_comments_activity (both dialects),
exact regardless of either window, and the page-local guard is removed
rather than kept as a dead one that reads as load-bearing.

* fix(store): item-scope the comment/activity link and freeze comment-linked activities against debounce merges (TASK-2760, codex r3)

The join keyed on activity id alone while nothing in the schema ties a
comment's activity to its item — scope both the LEFT JOIN and the NOT EXISTS
to the item. And CreateActivityDebounced could merge a later update into the
'updated' row a comment links to, overlaying its agent stamp and bumping
created_at, so two agents under one set of credentials would silently
re-attribute an earlier comment; comment-linked rows are no longer merge
targets. Prose corrected: the linked row is a 'commented' row OR the
'updated' row of an update that carried the comment.

* fix(server,web): keep the read-skew guard beside the SQL exclusion; nowrap on every 24ch agent label (TASK-2760, codex r4)

The page-local guard covers a distinct failure from the query exclusion —
a comment fetched then hard-deleted before the activity query runs — so it
returns with that reason written down. Sweep: of the seven 24ch agent-label
rules, three lacked white-space: nowrap (both timeline cards and
EpisodeFeed), so a name with spaces wrapped instead of ellipsizing; the
other four already had it. Prose nits corrected; the pre-link debounce race
on update-with-comment is recorded on BUG-2716 with a pointer in the handler.

* docs(server,cli): state the reverse read-skew at the guard and the CLI non-rendering decision (TASK-2760, codex r5)

* fix(store): debounce merge refuses a comment-linked row inside the UPDATE itself (TASK-2760, codex r6)

The read-then-write left a window in which a comment could link the chosen
row before the merge overwrote its agent stamp. The merge is now one
statement whose predicate re-checks the link under the row write, and a
zero-row merge falls through to a fresh insert. Prose corrected: a later
update looks past a frozen row, to an older unlinked one or a fresh one.

* fix(store,test): one freeze mechanism, and the window-edge leak proven end to end (TASK-2760, matrix survivors)

The debounce SELECT-side exclusion became redundant once the UPDATE's own
predicate refused linked rows, and its 'look past to an older unlinked row'
semantics folded a later change into an earlier entry — a linked row now
simply ends the coalescing run. And the server suite could no longer tell
the SQL exclusion from the restored in-memory guard, because it only
exercised the same-page case; a test now drives the page-edge case codex
found (comment outside its window, activity inside), where only the query
can help.

* fix(web): drop a duplicate nowrap in EpisodeFeed — the rule already had it (TASK-2760, codex r7)

Corrects the round-4 sweep count: of seven 24ch agent-label rules, two
lacked white-space: nowrap (both timeline cards), not three.
2026-08-24 18:09:02 -04:00
xarmian 927202a7a1 docs(web): put the leaf-not-fragment rule where the next edit will read it (TASK-2759)
Lead's one follow-up on the package: the round-8 reasoning had to live in the
code, not only in the evidence.

Two places. displayUser now says WHY the spoofing vector exists rather than
only what was done about it: this is ResolveAgentName's documented
attribution-honesty problem (agent_identity.go, "WHAT THIS IS NOT") arriving
through the renderer. The header records honesty rather than identity because
the actor authors it — and a surface that COMPOSES with an authored value
inherits that, handing the author influence over the parts they did not
write. The rule that falls out is stated for future edits: a self-declared
value is a leaf, never a fragment something else is built around. That covers
a new column, a tooltip, an export or a search summary, none of which exist
yet.

The same rule goes in agentActor.ts, since that is the file every surface
imports and the first place a maintainer looks. Stated there as what it is —
not a softening of the verbatim contract two paragraphs above it, because
isolation alters no characters and rejects no names; it refuses to let one
value redraw another.

Comments only; no behaviour change. It moves the tip, so the gate pointing at
501ba836 is void and I have re-asked rather than carrying the green across.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 18:22:02 +00:00
xarmian 501ba836f4 test(web): close the generic-id gap in the admin suite (TASK-2759)
The final mutation matrix, re-run on the tip, found one survivor in 26
(mutation, test-file) pairs: reinstating the retired GENERIC_AGENT_IDS filter
in the shared helper left the admin suite green, because every fixture there
used 'wren' — a value the filter would not have swallowed.

Exactly the hole I closed in the feed and audit-log suites earlier in this
run, repeated when I added this file eight commits later. The lesson landed
in two files and not in my habit, so it is now a case in all four.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 18:15:23 +00:00
xarmian 3a213d9918 test(web): cover the overview binding and agentNameOf's own contract (TASK-2759)
Codex round 14, reading the test files as a suite rather than one at a time.

The overview tab renders the same rows through its own markup and its own
writes-only filter, so it is a second BINDING and I had tested only the
first — my own CONVE-19 rule, missed on the surface I added two commits ago.

agentNameOf was pinned only by an equivalence assertion against the string
form. Four of the five surfaces call the parsed-object form, so its edges
were covered incidentally through component tests and not stated anywhere as
its contract. Direct cases now: named, generic id unfiltered, and every
not-a-name shape.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 18:07:59 +00:00
xarmian de3c9b818f feat(web): name the agent on the admin per-user activity views too (TASK-2759)
Codex round 12 — and it corrects MY exemption, not codex's reading of it.

I listed these two tabs as exempt because their local row type omitted
`metadata`. True, and the wrong reason: handleAdminGetUserActivity
serializes whole models.Activity rows, so the stamped name was on the wire
the entire time and only the client type dropped it. By this unit's own
discriminator — does the surface hold an Activity? — they were never exempt.
Verified against the handler before changing anything.

The consequence was the exact gap the audit log had, on the same rows: an
admin reading a user's activity saw "Updated an item via cli" with no way
to tell which agent acted. The lead ruled the audit log IN on this
discriminator; these belong in for the same reason.

Rendered with the same rules as every other surface — <bdi>, bounded at
24ch, title for the full value, nothing shown when no name was stamped.
Tests assert the binding at this surface (CONVE-19), including the empty
case, the non-agent case and the bidi one.

Docs updated: the surface list in the README and both SKILL.md copies now
names the admin console's audit AND per-user activity views. The precision
of that list is what round 2 was about, so it moves with the code.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:53:12 +00:00
xarmian 817bb0a5ce fix(web): widen the name bound off my own over-correction; assert bdi at every binding (TASK-2759)
Codex round 11.

P2 — the width bound was an over-correction, and it landed on people. The
activity page marks any actor_name `named`, so the 16ch rule I added for
hostile AGENT names was clipping ordinary human ones; the episode feed's 20ch
did the same. One value now, 24ch, which fits an ordinary full name
("Alexandra Whitfield" is 19) while still bounding the pathological case.
The number is written down as a judgement, not dressed up as a measurement,
next to what it does NOT cover: `title` is unreachable by touch, so a
clipped name is effectively unreadable on a phone, and a real disclosure
affordance rather than a wider bound is the actual fix.

P2 — only the audit cell asserted the <bdi> element; the other four bindings
checked text and classes, so swapping bdi back to span passed all of them.
Each now asserts the tag with a bidi-carrying name.

P2 on the casing tests, declined with the reason already in the code: they
assert the `named` class and not the CSS rule, because Svelte component
styles are not injected under this vitest setup (0 style elements, so
getComputedStyle resolves nothing). Covering the rule itself needs an e2e
with a real browser. The boundary is stated in the test comment rather than
implied away — a source-text assertion about the stylesheet would be an
instrument with an adversary, not coverage.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:43:52 +00:00
xarmian 46e3430f2b fix(web): restore the chip title, make the feed's fold-key test discriminating (TASK-2759)
Codex round 10, reading the assembled files rather than the diff — both
findings are the same class: a later round invalidated an earlier round's
premise, and nothing in either diff pointed at the other.

Round 5 removed the timeline chip's title because the chip never clipped.
Round 8 then bounded that label at 18ch to stop a hostile name widening the
card. So the label clips now and the reason for removing its title is gone;
a long name was being truncated with no way to read the rest. Title restored,
comment rewritten to say why it is there.

The EpisodeFeed test claiming to prove the fold key follows the agent name
put its two events on DIFFERENT items, which yields two cards no matter how
the actors are keyed — it only ever proved that labels render. Same item now,
same window, two names, and the card count is asserted, so a fold that
ignored the name produces one card and fails.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:33:25 +00:00
xarmian 0d08e7004c fix(web): isolate self-declared agent names so they cannot rewrite the audit around them (TASK-2759)
Codex round 8, probing adversarial names — the sharpest finding of the run
and a defect this unit introduced.

The agent name is text chosen by whoever is writing, and the admin audit log
built its cell as `${agent} (via ${human})`. A writer could therefore pick
a name that forges the construction (`admin (via root)` renders as nested
attribution), or one carrying U+202E, which reorders everything appended
after it — the audited party editing how the audit reads. Not an auth bypass:
the stored actor stays correct. It is an audit-integrity defect, on the one
surface whose job is to be trusted when trust is in question.

displayUser now returns the PARTS and the template renders them as separate
elements, each in its own <bdi>. That bounds a hostile name to its own
isolate: it still displays exactly as sent, but it cannot reorder the " (via
" literal or the account name, and the account half is structure rather than
string, so a name spelling "(via root)" is visibly text inside the agent's
element. The via span is styled distinctly for the same reason.

Swept the sibling renders rather than the reported one (CONVE-18): the two
badges, the episode label, the timeline chip and the human name beside it are
all <bdi> now, since every one of them sits inline next to other text.

P3 from the same round — the timeline chip and the audit User cell were the
two name surfaces still unbounded. Both bounded, ellipsis, full value on the
title where the element clips.

This does NOT weaken the verbatim contract, and the distinction is the whole
point: isolation changes no characters and rejects no names, it just renders
each value as its own unit. Storing raw and rendering safely are compatible;
an allow-list would not be.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:24:21 +00:00
xarmian 08b165af05 fix(web): restore the audit-log formatter guard I narrowed (TASK-2759)
Codex round 6, and it was my own regression from round 5. Hoisting the row
parse out of formatMetadata left its try/catch wrapped around only the parse
that had moved away, so the FORMATTERS below lost their guard. They can throw
on well-formed JSON — `String(data.keys)` cannot convert
`{"keys":{"toString":null}}` to a primitive — and what used to render an
em dash would now break the admin audit page.

The try now wraps the switch and the fallback, which is what it always
covered. Absent and unparseable metadata behave as before.

Also the test gap that let it through: this suite drove only action
`updated` and read only the User column, so nothing here could see
formatMetadata at all. Added two Details-column cases — the throwing one, and
a known action proving the hoisted object actually reaches the formatter
rather than only displayUser.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:12:10 +00:00
xarmian 454afe573a perf+a11y(web): one metadata parse per audit row, drop a redundant tooltip (TASK-2759)
Codex round 5.

P2 — the admin audit log parsed each row's metadata twice once this unit
added a second reader (displayUser alongside formatMetadata), on a table that
grows through "Load more". Hoisted to one `parseMetadata` per row, passed
to both. formatMetadata now takes the parsed object, which also removes the
try/catch it no longer needs.

Left alone, with the reason: the dashboard also reads metadata twice per row,
but it renders at most ten and its other reader (parseActivityChanges) has
callers outside this diff whose signature I am not changing for ten rows.

P3 — the title I put on the timeline actor chip duplicated text that is
always fully visible: that row wraps and the chip never clips, so it added no
information and gives assistive technology the same string twice. Removed.
The titles on the two badges and the episode label stay — those DO clip, and
there the attribute is the only way to the full value.

Codex reported clean on reachability (no code-reachable wrong-name case
beyond the documented self-declared limitation and the excluded BUG-2763) and
on history coherence.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:07:45 +00:00
xarmian e09216dfc4 fix(web): keep named actors out of the anonymous fold key; type the dashboard fixture (TASK-2759)
Codex round 4.

P2 — the fold key was built from the DISPLAY LABEL, so an agent that sends
`agent` in X-Pad-Agent folded together with every agent that sent no name at
all: two different claims ("this actor" and "we have no name for this
actor") sharing one key, which also contradicted the file's own comment
about a named agent getting its own key. Named and anonymous are now separate
namespaces. Swept the sibling rather than the reported half (CONVE-18): the
user branch had the identical defect for a person whose display name is
`cli` or `web`. Both fixed, both asserted.

P2 test gap — the dashboard route fixture was typed `Activity`, but
`recent_activity` is a REDUCED DTO with no id/workspace_id/document_id and
an OPTIONAL metadata. A fixture richer than the real payload cannot fail when
the payload changes, and it hid a reachable case: rows logged by the audit
helpers never call agentMeta, so absent metadata is a shape the server really
sends. Fixture now derives from DashboardResponse and both route suites cover
absent metadata.

P2 on activity debounce — real, verified against the store rather than taken
on report, and outside this unit's web-only boundary. CreateActivityDebounced
matches on (document, action, user) without actor, and its UPDATE leaves the
original `actor` in place while mergeActivityMeta overlays the newer
`agent` name, so a coalesced row can name the wrong writer in either
direction. Filed as BUG-2763 with both orderings worked through. This unit did
not cause it; it is the first thing to make it visible.

Codex also reported SSR/hydration CLEAN and verified this diff's claims about
the Go side against the Go code.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:01:33 +00:00
xarmian 6dc6499a6e refactor(web): drop the one-use helper wrapper, bound name width (TASK-2759)
Codex round 3.

P3 — `agentActorLabel` had a single caller. Every other site already holds
parsed metadata and reaches for `agentNameOf`, and each supplies its own
fallback anyway, so the wrapper saved one `?? 'agent'` and cost an
inconsistency in how five call sites looked. Removed; the reasoning stays in
the file so it is not re-added.

P2 — names are unbounded text in fixed-layout rows. Before this unit the
agent badge held one of four fixed words; it now holds whatever a client put
in X-Pad-Agent, while still being `flex-shrink: 0`, so one long name pushes
the timestamp off the row. Bounded with an ellipsis at the two badges and the
episode actor label (which has the same exposure for people's names, and had
it before this change), with the full value on the title attribute. The
timeline chip and the audit-log cell both wrap, so they take the title only.

P2 on presentation consistency — three surfaces show the (agent, human) pair
three ways, and this unit invented one of the three. Declined as scope rather
than as wrong, and filed as IDEA-2762 with what a decision has to cover.

Codex also independently confirmed the exempt set: comments, versions and
structured entries genuinely do not carry the name, and the linked comment
activity that does is skipped when the card renders.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:52:09 +00:00
xarmian fa22b6680e docs+test: correct two over-claims and pin name escaping (TASK-2759)
Codex round 2, fresh angles.

P1, accepted — my own docs over-claimed. The README and both SKILL.md
copies said the name appears wherever agent actors appear, including "item
timelines". Comments, version snapshots and note/decision entries carry the
actor KIND and no name (that is the exempt set the plan named, and TASK-2760
files the comment half), so on a timeline only ACTIVITY entries show it. Both
now say which entries carry it and which read "Agent".

P2, accepted — the README's fallback was wrong in a way that mattered. When
nothing resolves a name, the CLI omits X-Pad-Agent entirely (client.go:1884),
so actorFromRequest records the write as "user": it is attributed to the
PERSON, not to a generic "agent". Verified both call sites rather than
reasoning from the label. The generic "agent" rows that do exist come from
pre-naming writes and from audit events logged without agentMeta.

P2, accepted — the name is attacker-influenced text and every test used
benign values, so a rewrite to {@html} would have passed. Added a markup
payload at two surfaces that build their labels through different paths,
asserting no element is created and the text survives intact.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:45:35 +00:00
xarmian d1c5c3976e fix(web): stop the badge CSS upper-casing a stamped agent name (TASK-2759)
Codex round 1. `.actor-badge` sets text-transform: uppercase, so the
activity page's audit rows and the dashboard's recent activity rendered
`Wren` and `wren` as the same pixels — the verbatim contract broken in
CSS rather than in code, and invisible to every textContent assertion in
the suite.

The codebase already draws this line: `.actor-badge.user` opts out of the
transform, because a human's badge carries a NAME while "agent" / "cli" /
"web" are CATEGORY words that read as chips. A stamped agent name is a
name, so it follows the same rule via a `named` modifier; the generic
fallback stays a chip.

Swept the class rather than fixing the two reported sites (CONVE-18): the
other three surfaces are unaffected — Chip has no transform, EpisodeFeed's
uppercase rule is .section-label ("HAPPENING NOW"), and the audit log's
cell is untransformed. Two sites, both fixed.

Tests assert the class the markup applies, and say so: Svelte component
styles are not injected under this vitest setup (0 style elements, so
getComputedStyle resolves nothing), which leaves the adjacent CSS rule
outside what the suite can observe. The class is the half a refactor drops.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:40:05 +00:00
xarmian 39844197ce test(web): close two coverage holes the mutation matrix found (TASK-2759)
Per-file negative controls showed EpisodeFeed and the console audit-log
tests staying GREEN when the retired GENERIC_AGENT_IDS filter was reinstated:
neither file used a value the filter would have swallowed, so both measured
'a name reaches the surface' without measuring 'an unfiltered name does'.
One claude-code fixture each. 7/7 mutations now detected per-file.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:26:41 +00:00
xarmian 0719286910 test(web): assert the agent-name binding from each consuming surface (TASK-2759)
Five render sites, five consuming-side assertions (CONVE-19). The helper has
its own unit tests, and a correct helper that a page never calls — or calls
with the wrong argument — passes every one of them; the Audit view's defect
was exactly that shape, with the metadata parsed three lines above the call
that ignored it.

Each file's load-bearing legs are the negative ones: the generic label a
pre-fix build produced is asserted absent where a name is stamped, and
asserted present for every stamp shape that carries no name (missing key,
empty string, non-string, unparseable). Two also pin that a non-agent row
never reads the stamp, since the metadata blob is shared and agentMeta
merges into it by string splice.

activityEpisodes.test.ts's shim case inverted with the shim it named.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:24:14 +00:00
xarmian 7e7d6e8efa feat(web): render agents' stamped names wherever agent actors display (TASK-2759)
The input half has existed since BUG-2542: the CLI resolves an agent name
(.pad.toml agent_name -> $PAD_AGENT -> detected runtime) and sends it as
X-Pad-Agent, and the server stamps it into activity metadata as `agent`.
Nothing rendered it. Every agent write displayed as an undifferentiated
"agent", and on the console audit log it displayed under the name of the
HUMAN whose credentials the write rode on.

Recon's discriminator: metadata.agent is stamped only by agentMeta(),
reached only from logActivityWithMetaReturningID, so workspace Activity
rows are the only carrier in the data model. Comments, versions, items,
structured note/decision entries and SSE events record the actor KIND and
no name. That makes render-vs-exempt mechanical rather than per-surface
judgement: does this surface hold an Activity?

Rendering (5 sites, each already holding the metadata):
  - the activity page's Live view fold (activityEpisodes.ts)
  - the activity page's Audit rows (getSourceLabel)
  - the dashboard's Recent Activity rows
  - TimelineActivityCard on the item timeline
  - the console audit log's user column

Exempt, name absent from the payload: comment authorship (TASK-2760 files
the server half), version cards, structured note/decision cards, the SSE
toast, ItemDetail's "Created by", and the console UserActivityTab (its row
type omits metadata).

Retires the GENERIC_AGENT_IDS shim on its own stated retirement condition
(CONVE-2757 rule 4, PR #1192): it filtered a hardcoded list of one team's
client ids out of the Live view, which made display quality depend on that
team's naming habits. Names now render verbatim -- no allow-list, no
normalization, no title-casing; any transform is a doorway for a
workspace's vocabulary to re-enter product logic. Historical claude-code
rows render as claude-code, which is honest: a reader learns every write
came from one undifferentiated client, which the filter concealed.

The audit log renders both facts rather than replacing one with the other
("wren (via Dave)") -- the agent acted, and that account is who it acted
as, and an ops surface needs both.

The shim's test inverted with it, and the file's header doc asserted that
"every current seat sends the generic client id claude-code" -- falsified
by this change, so rewritten rather than left (CONVE-23).

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:15:44 +00:00
dependabot[bot] 3f9daa5c7a chore(deps)(deps): bump the npm-minor-and-patch group (#1190)
Bumps the npm-minor-and-patch group in /web with 21 updates:

| Package | From | To |
| --- | --- | --- |
| [@tiptap/core](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/core) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-bubble-menu](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-bubble-menu) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-code-block-lowlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-code-block-lowlight) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-collaboration](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-collaboration-caret](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration-caret) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-link](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-link) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-placeholder](https://github.com/ueberdosis/tiptap/tree/HEAD/packages-deprecated/extension-placeholder) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-table](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-table) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-task-item](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-item) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-task-list](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-list) | `3.30.1` | `3.30.2` |
| [@tiptap/pm](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/pm) | `3.30.1` | `3.30.2` |
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) | `3.30.1` | `3.30.2` |
| [@tiptap/suggestion](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/suggestion) | `3.30.1` | `3.30.2` |
| [@tiptap/y-tiptap](https://github.com/ueberdosis/y-tiptap) | `3.0.8` | `3.0.9` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.13` | `3.4.14` |
| [mermaid](https://github.com/mermaid-js/mermaid) | `11.16.1` | `11.17.0` |
| [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) | `2.70.2` | `2.70.3` |
| [marked](https://github.com/markedjs/marked) | `18.0.9` | `18.0.10` |
| [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) | `5.56.9` | `5.56.10` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.1` | `8.2.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.10` | `4.1.11` |


Updates `@tiptap/core` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/core/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/core)

Updates `@tiptap/extension-bubble-menu` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-bubble-menu/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-bubble-menu)

Updates `@tiptap/extension-code-block-lowlight` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-code-block-lowlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-code-block-lowlight)

Updates `@tiptap/extension-collaboration` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-collaboration/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-collaboration)

Updates `@tiptap/extension-collaboration-caret` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-collaboration-caret/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-collaboration-caret)

Updates `@tiptap/extension-link` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-link/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-link)

Updates `@tiptap/extension-placeholder` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages-deprecated/extension-placeholder/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages-deprecated/extension-placeholder)

Updates `@tiptap/extension-table` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-table/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-table)

Updates `@tiptap/extension-task-item` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-task-item)

Updates `@tiptap/extension-task-list` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-task-list)

Updates `@tiptap/pm` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/pm/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/pm)

Updates `@tiptap/starter-kit` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/starter-kit/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/starter-kit)

Updates `@tiptap/suggestion` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/suggestion/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/suggestion)

Updates `@tiptap/y-tiptap` from 3.0.8 to 3.0.9
- [Changelog](https://github.com/ueberdosis/y-tiptap/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/y-tiptap/commits)

Updates `dompurify` from 3.4.13 to 3.4.14
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.13...3.4.14)

Updates `mermaid` from 11.16.1 to 11.17.0
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.16.1...mermaid@11.17.0)

Updates `@sveltejs/kit` from 2.70.2 to 2.70.3
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/@sveltejs/kit@2.70.3/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.70.3/packages/kit)

Updates `marked` from 18.0.9 to 18.0.10
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.9...v18.0.10)

Updates `svelte` from 5.56.9 to 5.56.10
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.10/packages/svelte)

Updates `vite` from 8.2.1 to 8.2.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.2.2/packages/vite)

Updates `vitest` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)

---
updated-dependencies:
- dependency-name: "@tiptap/core"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-bubble-menu"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-code-block-lowlight"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration-caret"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-link"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-placeholder"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-table"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-item"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-list"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/pm"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/starter-kit"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/suggestion"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/y-tiptap"
  dependency-version: 3.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: mermaid
  dependency-version: 11.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@sveltejs/kit"
  dependency-version: 2.70.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: svelte
  dependency-version: 5.56.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 11:58:36 -04:00
xarmian 8a95d29a15 chore(web): name the GENERIC_AGENT_IDS shim's retirement condition (CONVE-2757) (#1192)
Product code temporarily encoding a convention-shaped assumption carries
the item whose completion deletes it: IDEA-2750 part 1.

Claude-Session: https://claude.ai/code/session_01HvAuiZ7JaWyCqqyV99LyWt
2026-08-24 11:26:44 -04:00
xarmian d4e7be4a24 feat(web): Live view on the activity page — the feed folded into episodes (IDEA-2755)
An episode is a run of consecutive events by one actor on one item, split
on a 30m gap: audit-grain rows become work-grain cards. The Live/Audit
toggle persists per browser; server HTML and the hydration pass both
render the 'live' default and the stored choice applies in onMount,
strictly after hydration. Liveness is claimed only from event age.
Live cards enrich with the newest comment's first line (best-effort,
first four only, no polling) — the trail's checkpoint discipline is what
makes that line worth showing.

Seat identity: the fold reads metadata.agent (the X-Pad-Agent stamp);
generic client ids render as 'agent', and a seat that sends its own name
lights up its label with no further change — concept B's lanes want
exactly that.

Design canvas and decision record on IDEA-2755. Review loop: 4 rounds,
5 findings fixed (wire-contract phantom, agent metadata field, Node-25
localStorage guard, hydration mismatch, cross-type fixture bleed).

Claude-Session: https://claude.ai/code/session_01HvAuiZ7JaWyCqqyV99LyWt
2026-08-24 14:17:35 +00:00
xarmian ad0deacb43 fix(web): Activity's item_id was a phantom — the wire field is document_id
internal/models/activity.go serializes the referenced item's UUID as
document_id (the audit trail predates the document→item rename); the TS
Activity type declared item_id, which no server response ever carries.
Nothing read it until the episode fold tried to — its primary key never
fired and ref-less rows would have folded into one workspace episode.
The timeline test fixture carried the same phantom field, internally
consistent with the type and unlike any real payload.

Note the deliberate asymmetry: Comment's wire field IS item_id
(models/comment.go) — the two types genuinely differ, which is exactly
how the phantom survived review.

Claude-Session: https://claude.ai/code/session_01HvAuiZ7JaWyCqqyV99LyWt
2026-08-24 14:17:35 +00:00
xarmian 5003718802 fix(push): apply delivery's visibility gate to delivered_sessions (BUG-2725) (#1187)
deliveredSessionCount applied three of watchNotificationVisible's four
gates, missing the first thing delivery checks: vis.allows(CollectionID,
ItemID). Broadcast over-reported. Targeted was worse — the publish-skip
reads this count, so the gate passed, the push went out, the stream
dropped it on visibility, and the response said delivered_sessions: 1.
An instruction lost behind a success.

Per Dave's day-49 ruling, visibility is RE-RESOLVED at push time rather
than snapshotted: membership and grants are revocable, so a value cached
at connect goes wrong exactly when revocation is what makes it matter.

The one input that cannot be re-resolved is the target connection's auth
transport — computeWatchAccessVisibility consults isBearerAuth exactly
once, inside the admin bypass, and the pushing request only knows its
own. So SessionOrigin.BearerAuth is recorded at Add(). That is NOT the
snapshot the ruling rejected: auth transport is a property of the
connection, fixed when it opened and not revocable while held, so it
cannot go stale. Armed is the precedent. SessionOrigin is kept separate
from SessionIdentity because that type documents itself as self-declared
and never verified; folding a server-derived security fact in there
would silently retract the warning for one field. Both comments state
the rule for future extenders: connection properties are admissible,
derived authorization state never is.

computeWatchAccessVisibility now takes a bool instead of an
*http.Request, which makes the per-connection input visible in the
signature and lets the count answer for a connection it is not serving.

COST: "re-resolve per counted session" reads like N access checks per
push. It is at most TWO, and sessionVisibility's memo makes that true by
construction rather than by careful calling — every other input is
per-user and identical across the sessions counted, so one varying
boolean bounds the answers at two. Pinned by a test with 50 sessions.

Codex round 1 (P1): the first version swallowed store errors into "not
visible", reintroducing BUG-2698 through this fix — a targeted push
reporting 0 SKIPS the publish, so a DB blip would drop the instruction
and answer 200, in a function whose own doc comment says why 0 is
load-bearing. Round 2 (P1): the same class one layer down —
computeWatchAccessVisibility collapsed FOUR store failures into a
denial, two discarded into underscores. Fixed as a class per CONVE-18.
Resolution and policy are now separate: stream-side callers discard the
error explicitly with reasons, only the counting caller propagates.
Round 3 CLEAN.

CONVE-23 sweep found three consumer-facing artifacts still describing
the old mechanism, none on a line this diff touched: the plugin skill
doc, the web push dialog, and pad push --help. All three corrected to
name what actually remains rather than deleting the caveat. Plugin
0.3.1 -> 0.3.2, since installed plugins are version-pinned at install.

NOT fixed, deliberately: the UNDER-count. A stream past
maxSessionsPerUser receives broadcasts while never entering the
registry. delivered_sessions remains an estimate with error in both
directions, and every consumer-facing description now says so.

Two coverage gaps recorded rather than rounded off: mutation M11
survives (the reporting test reaches only the first of four store calls,
because closing the DB fails it first), and no test drives the whole
chain store-fault-to-503 (the DB-close instrument kills the request
earlier, so such a test would have gone green against the wrong 500 —
deleted rather than relaxed).

Also lands the BUG-2752 refutation sentinel: that item claimed the OAuth
workspace allow-list went unenforced on /api/v1/events/stream. Refuted —
no allow-list-bearing credential can authenticate to /api/v1/* at all.
The test guards that format gate, so if it ever widens, the refutation's
premise fails loudly instead of silently reopening a leak.

Gates on the merged tip: make test 27 pkgs, make lint 0 issues, full
Postgres suite 27 pkgs, govulncheck, codex CLEAN, CI 7/7.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 09:45:48 -04:00
dependabot[bot] 0ccf178e91 chore(deps)(deps-dev): bump jsdom from 26.1.0 to 29.1.1 in /web (#1141)
* chore(deps)(deps-dev): bump jsdom from 26.1.0 to 29.1.1 in /web

Bumps [jsdom](https://github.com/jsdom/jsdom) from 26.1.0 to 29.1.1.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v26.1.0...v29.1.1)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* test(a11y): emulate the :modal-unsupported engine explicitly under jsdom 29

jsdom 26 threw on the :modal pseudo-class, so the fallback-path tests in
viewerBackdrop.svelte.test.ts ran their premise on the bare environment
for free. jsdom 29 PARSES :modal but never matches it (the setup-jsdom
showModal polyfill sets no top-layer state), which the module's probe
reads as a supporting engine — flipping three tests off the path their
titles name and silently shifting a fourth.

The unsupported engine is now emulated the same way the supporting one
always was: mockModalUnsupported() throws SyntaxError from every probe
the module makes (querySelector, querySelectorAll, Element.matches),
per the re-take note on TASK-2586.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: xarmian <xarmian@gmail.com>
2026-08-23 08:46:16 -04:00
dependabot[bot] ac0f1180a9 chore(deps)(deps): bump the npm-minor-and-patch group across 1 directory with 17 updates (#1140)
Bumps the npm-minor-and-patch group with 16 updates in the /web directory:

| Package | From | To |
| --- | --- | --- |
| [@dagrejs/dagre](https://github.com/dagrejs/dagre) | `3.1.0` | `3.1.1` |
| [@tiptap/core](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/core) | `3.29.2` | `3.30.1` |
| [@tiptap/extension-bubble-menu](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-bubble-menu) | `3.29.2` | `3.30.1` |
| [@tiptap/extension-code-block-lowlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-code-block-lowlight) | `3.29.2` | `3.30.1` |
| [@tiptap/extension-collaboration](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration) | `3.29.2` | `3.30.1` |
| [@tiptap/extension-collaboration-caret](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration-caret) | `3.29.2` | `3.30.1` |
| [@tiptap/extension-placeholder](https://github.com/ueberdosis/tiptap/tree/HEAD/packages-deprecated/extension-placeholder) | `3.29.2` | `3.30.1` |
| [@tiptap/extension-table](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-table) | `3.29.2` | `3.30.1` |
| [@tiptap/extension-task-item](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-item) | `3.29.2` | `3.30.1` |
| [@tiptap/extension-task-list](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-list) | `3.29.2` | `3.30.1` |
| [@tiptap/pm](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/pm) | `3.29.2` | `3.30.1` |
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) | `3.29.2` | `3.30.1` |
| [@tiptap/suggestion](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/suggestion) | `3.29.2` | `3.30.1` |
| [@sveltejs/vite-plugin-svelte](https://github.com/sveltejs/vite-plugin-svelte/tree/HEAD/packages/vite-plugin-svelte) | `7.2.0` | `7.3.0` |
| [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) | `5.56.8` | `5.56.9` |
| [svelte-check](https://github.com/sveltejs/language-tools) | `4.7.5` | `4.7.6` |



Updates `@dagrejs/dagre` from 3.1.0 to 3.1.1
- [Release notes](https://github.com/dagrejs/dagre/releases)
- [Changelog](https://github.com/dagrejs/dagre/blob/master/changelog.md)
- [Commits](https://github.com/dagrejs/dagre/compare/v3.1.0...v3.1.1)

Updates `@tiptap/core` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/core/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages/core)

Updates `@tiptap/extension-bubble-menu` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-bubble-menu/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages/extension-bubble-menu)

Updates `@tiptap/extension-code-block-lowlight` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-code-block-lowlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages/extension-code-block-lowlight)

Updates `@tiptap/extension-collaboration` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-collaboration/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages/extension-collaboration)

Updates `@tiptap/extension-collaboration-caret` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-collaboration-caret/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages/extension-collaboration-caret)

Updates `@tiptap/extension-link` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-link/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages/extension-link)

Updates `@tiptap/extension-placeholder` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages-deprecated/extension-placeholder/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages-deprecated/extension-placeholder)

Updates `@tiptap/extension-table` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-table/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages/extension-table)

Updates `@tiptap/extension-task-item` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages/extension-task-item)

Updates `@tiptap/extension-task-list` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages/extension-task-list)

Updates `@tiptap/pm` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/pm/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages/pm)

Updates `@tiptap/starter-kit` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/starter-kit/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages/starter-kit)

Updates `@tiptap/suggestion` from 3.29.2 to 3.30.1
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/suggestion/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.1/packages/suggestion)

Updates `@sveltejs/vite-plugin-svelte` from 7.2.0 to 7.3.0
- [Release notes](https://github.com/sveltejs/vite-plugin-svelte/releases)
- [Changelog](https://github.com/sveltejs/vite-plugin-svelte/blob/main/packages/vite-plugin-svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/vite-plugin-svelte/commits/@sveltejs/vite-plugin-svelte@7.3.0/packages/vite-plugin-svelte)

Updates `svelte` from 5.56.8 to 5.56.9
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.9/packages/svelte)

Updates `svelte-check` from 4.7.5 to 4.7.6
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check@4.7.5...svelte-check@4.7.6)

---
updated-dependencies:
- dependency-name: "@dagrejs/dagre"
  dependency-version: 3.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@sveltejs/vite-plugin-svelte"
  dependency-version: 7.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/core"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-bubble-menu"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-code-block-lowlight"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration-caret"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-link"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-placeholder"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-table"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-item"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-list"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/pm"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/starter-kit"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/suggestion"
  dependency-version: 3.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: svelte
  dependency-version: 5.56.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: svelte-check
  dependency-version: 4.7.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-23 08:28:19 -04:00
xarmian c017ad359d fix(events): give each in-memory bus incarnation its own ID space (BUG-2736)
Both in-process buses assigned Last-Event-ID values from a counter that
restarted at 1 on every process start. A client holding cursor 2 from a
previous incarnation could reconnect to a restarted server, pass every
coverage check BUG-2731 added, and be replayed the NEW space's 3, 4, 5 as
though they followed the OLD space's 2 -- silently missing everything the
dead space held above 2.

Nothing local could tell the two 2s apart. The cursor carries no epoch, and
in internal/events per-workspace IDs are non-consecutive by construction, so
"did we issue this ID?" was numerically undecidable. The four adjacent levers
were checked rather than assumed: comparing in memory has nothing to compare
against; persisting the counter makes single-process Pad carry durable
event-bus state and still resets on data loss; refusing cursors we did not
issue is the undecidable one; and a nonce on a second channel is unavailable
because EventSource echoes Last-Event-ID and nothing else, and cannot rewrite
its URL on an automatic reconnect.

So the ID space's identity goes in the ID's VALUE while its FORMAT is
unchanged: still a bare int64, still ParseInt on the way back. internal/idspace
mints a base of processStartUnixMilli<<20 and each bus counts up from it. Two
incarnations can only collide if the earlier process published more than 2^20
events per millisecond of its own lifetime -- a deterministic bound, not the
probabilistic one BUG-2736's body rules out. A CAS makes bases strictly
increasing within a process too, which the clock alone does not do for two
buses constructed in the same millisecond.

A backwards clock step degrades in the SAFE direction: a lower base puts old
cursors ABOVE the new buffer's newest ID, so they are refused rather than
answered wrongly. The overflow bound is computed, not estimated: the last
start instant that fits is 2248-09-26T15:10:22Z.

Each bus then answers the resume question exactly instead of inferring it: a
non-zero cursor at or below this incarnation's base was issued by a dead
space. That is strictly stronger than the coverage check alone, which serves
the ADJACENT cursor on reasoning that only holds within one ID space.

In internal/watchevents the check lives in one helper both entry points call.
Written inline in EventsSince it was absent from SubscribeAndReplaySince --
the path the SSE handler actually uses -- so the component was fixed and its
wiring was not (team CONVE-19). A test now drives both.

web's ItemEvent no longer declares `id?: number`. Nothing read it, which is
the only reason it was harmless; a base of ~1.8e18 is past JavaScript's
MAX_SAFE_INTEGER, so the first reader would have silently got a rounded
number. Defused while still unread.

Tests that spelled out IDs now read back what the bus assigned -- a literal 1
is a cursor from a dead space, which turned two negative controls into their
own opposite. The two watchevents guards (cold buffer, dead incarnation) are
tested separately, because a single test covering both would keep passing
with either deleted.

The Redis half is not here. Its counter is shared across processes, so
identifying its ID space needs an epoch travelling with each message; that is
the next commit on this branch.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-22 18:14:45 +00:00
xarmian ea139272ce fix(server,watchevents): shared session presence + honest push acceptance (BUG-2698, BUG-2699) (#1175)
Two coupled defects in the push path, fixed as one unit because 2699's honest-acceptance signature is the substrate 2698's fix reports through.

BUG-2699 — Bus.Publish reports acceptance. The endpoint returned 200 pushed:true
for a publish that was dropped, because Publish returned nothing and swallowed
every failure. An error is two outcomes and they are kept apart: ErrBusClosed
proves nothing was published (503 unavailable, safe to resend), while any other
error means UNCONFIRMED — go-redis retries a command whose reply was lost, which
is why the publish script already carries a dedupe token — and gets 502
push_unconfirmed, deliberately off the web client's safe-to-resend list.
MemoryBus was the worse case, not the exempt one: neither implementation checked
`closed`, and the in-process one dropped silently with no log at all. Seven
production call sites, not the six the item named; the six best-effort producers
discard through one named helper, and an AST-based test fails when a new
producer publishes directly.

BUG-2698 — RedisSessionPresence. A session-targeted push was resolved against
the answering replica's presence registry, and the handler skips the publish
when the target is absent, so a POST landing on A for a session held on B
dropped the instruction and answered delivered_sessions:0. Fixed at the REGISTRY
rather than the gate: a shared registry makes the snapshot right, which makes
the picker complete and restores the gate's original premise, so the existing
skip becomes correct for the reason it was written. Entry and index are written
atomically under a TTL renewed by a goroutine that lives exactly as long as the
connection; a crashed process stops renewing and Redis clears it. Staleness is
unchanged and now stated in full: ~30s for a dropped client, ~90s for a dead
instance.

delivered_sessions becomes nullable — null means published-but-uncountable,
never zero — documented as three states at every consumer.

35 Codex review rounds. Notable: a per-user registry cap was added and then
removed after three consecutive rounds found defects inside it and a fourth was
asked whether it belonged in this PR at all; a context bound was documented,
disproved by its own test (go-redis does not apply a command context to
connection establishment — 5.0s measured against a 150ms ctx), and rewritten to
say what is true. Every fix was mutation-checked; one instrument was deleted for
passing on broken code and one for not asserting its own premise.

Filed rather than folded in: BUG-2724 (Redis keyspace namespacing + Cluster),
BUG-2725 (delivered_sessions is an estimate with error in both directions),
BUG-2726 (no concurrent-connection limit on the watch stream), BUG-2727 (Redis
absent from readiness/metrics; silent subscriber loss), BUG-2728 (epoch-reset
resume lead).

Gates: build · make lint 0 issues · go test ./... (25 pkgs) · svelte-check 0
errors · vitest 1738 passed · CI 7/7 including Go (PostgreSQL) and Nix.
2026-08-21 20:43:20 -04:00
xarmian 402f79e016 feat(store,server,web): collection kernel traits — de-hardcode conventions/playbooks slugs (TASK-2657, BUG-2702) (#1171)
Implements SPEC-5 §Collection traits (approved v1.1) — the first unit of
PLAN-2656 phase 0. Three kernel behaviors were keyed on the literal collection
slugs "conventions" and "playbooks": what the agent bootstrap loads, which
items route by invocation slug, and which items export as portable artifacts.
Collections now DECLARE those behaviors and the kernel resolves them from the
declarations.

Fixes the KERNEL half of BUG-2702, which stays open for the rest (see below).
A slug is not a stable identifier —
UpdateCollection re-slugs on any name change, and renaming a collection is a
documented onboarding step (TASK-1510) — so renaming either collection silently
detached all three behaviors from it, with the items still present and no error
anywhere. Measured on origin/main before the fix: conventions and
convention_index dropped 1 -> 0, playbooks 1 -> 0, and GET /playbooks/{slug}
went 200 -> 404, so `/pad ship` stopped resolving with no sign the playbook
still existed. Both halves are locked by regression tests observed failing on
unfixed code.

BUG-2702 is NOT fully closed here, deliberately. Every kernel behavior follows
the trait, and library activation on the MCP dispatcher and CLI was converted
too — but the pack's own dedicated web routes (/conventions, /playbooks list and
detail, /library) still address their collection by literal slug and render
empty after a rename. Filed as BUG-2705 with the route paths and the likely fix
shape; 2702 closes when that lands. Degradation there is bounded: no data loss,
and the collection stays usable at its own /[collection] route and in the
sidebar.

SPEC-5 was amended to v1.1 BEFORE any code, per the spec tree's own discipline:
bootstrap_include becomes a LIST of {mode, filter, key} because v1.0 could not
express convention_index at all; the conventions filter is now normative and
includes status=active, which v1.0's shorthand omitted and which the
implementation does enforce (implementing v1.0 literally would have leaked
draft conventions into every agent's boot payload); v1 filters are field-
equality maps with query/1 named as the widening path, since SPEC-2 is phase 1
and PLAN-2656 forbids growing toward it; and invocation_field is constrained to
the literal `invocation_slug`, because any other field name falls outside the
partial unique indexes in migrations/054 and pgmigrations/033 that are the real
uniqueness guard.

Traits get their own column rather than a key inside the schema JSON. The
schema column is overwritten wholesale on update and every client rebuilds it
fields-only, so a traits key stored there is destroyed by one ordinary
collection edit — measured during this task, not assumed. Trait authority
cannot rest on a value an unrelated UI save deletes. UpdateCollection writes
traits only when explicitly supplied, so pre-existing clients leave them alone;
an explicit "{}" still clears.

Bootstrap keeps its three payload keys as first-party views fed from the
declarations, and gains a generic bootstrap_includes array for any other
declared key — so the boot surface is genuinely generic rather than three
hardcoded payloads, and no consumer breaks.

Existing workspaces are backfilled slug-keyed in both dialects, guarded on
traits='{}' so a re-run cannot clobber a workspace's own declarations. The
backfill inherits today's blind spot (a workspace that renamed the collection
before upgrading is not reached) but cannot do worse than the status quo, which
is itself slug-keyed; from the backfill forward the hazard is structurally gone.

Malformed declarations are refused at create and update rather than stored:
an unparseable blob degrades to "declares nothing", which is silently the wrong
behavior instead of a loud error (SPEC-0 L6).

Web groups agent-facing collections by bootstrap_include presence, replacing a
hardcoded two-slug array repeated at five call sites.

Not done, deliberately: no MCP catalog change (traits are first-party kernel
declarations, no agent needs to set them, and the separate column means
pad_collection.update passes through harmlessly — no ToolSurfaceVersion bump);
bootstrap's collections[] projection does not carry traits (PLAN-1410 trimmed
that payload and nothing consumes them there); prefix.go's NormalizeSlug is
untouched (a pure function with no workspace context, and de-hardcoding two of
its six slugs would make it less coherent, not more).

Eight Codex review rounds found nineteen real defects, all fixed here. The
serious one:
bootstrap_include filter keys FAIL OPEN. The item store's field-filter path
drops any key its sanitizer rejects, removing the predicate rather than matching
nothing, so a declaration filtering on `"stat us"` would narrow nothing and ship
every convention — drafts included — to every agent at boot, defeating the
status=active guarantee this change makes normative. Filter keys are now
validated against the store's own sanitizer shape and pinned by a cross-package
agreement test, since models cannot import store and a future divergence would
silently reopen it. SPEC-5 amended to v1.2 with the rule and its fail-open /
fail-closed asymmetry. Also fixed: an unknown declared artifact_kind reached
artifact.Encode and surfaced as a 500 (now a 400 at the export boundary, since
SPEC-5 permits unknown kinds as legal non-round-tripping declarations); and
workspace import validated traits as JSON only, so an archive could persist a
declaration that degrades to "declares nothing" (now validated, degrading to
"{}" with a warning rather than refusing an import that may be the only copy).

Later rounds found more, and several were defects this change itself created.
A hidden collection could SHADOW a visible one: resolution used to name exactly
one collection, so with several declaring, resolving across all of them and
rejecting afterwards on visibility made a visible playbook unreachable behind a
hidden one — candidates are now filtered by visibility before selection, in both
playbook resolution and artifact import. Importing a pre-traits archive produced
an INERT workspace: the migration backfill cannot reach rows inserted long after
it ran, so conventions/playbooks arrived declaring nothing, and canonical
declarations are now inferred from the slug when a collection declares none
(never overriding declarations that survived the round trip). The generic
include path had no L4 boot budget and is now capped with an overflow count.
Trait parsing claimed to be strict but json.Decoder ignores trailing bytes, so
`{...} garbage` parsed cleanly. First-party payload keys are now mode-pinned,
since their projections have fixed shapes and declaring the other mode would be
silently ignored. Duplicate artifact_kind / invocation_field declarations are
refused at the collection API, and a conflicting archive warns on import.

Agent-facing text was updated with the rest, not after it: SKILL.md,
instructions.md and the MCP catalog said the literal slugs, which is exactly the
artifact an agent acts on. ToolSurfaceVersion 0.24 -> 0.25 for the
pad_library.activate behaviour change.

Trait uniqueness is a documented BEST-EFFORT gate, not an invariant, by lead
ruling. The gate reads then writes without a lock, import bypasses it, and a
rename can mint a duplicate without touching that path. The database-level
enforcement (partial unique indexes on the extracted traits) cannot ship first:
existing deployments can already hold duplicates via rename-then-reseed, so the
index would fail the migration on precisely the databases that most need
repairing. TASK-2710 carries the de-duplication pass and the indexes; SPEC-5
v1.3 records the deferral and the reason. L6's requirement that conflicts fail
loud is met by the refusal plus the warning — the mechanism is deferred, the
principle is not.

Gates: build · make lint 0 issues · go test ./internal/... · make test-pg ·
svelte-check 0 errors · vitest 99 files / 1734 tests. Mutation-verified across
four matrices, 20 mutations, 19 caught; the survivor is a seeding path whose
trait-vs-slug difference is unreachable today (SeedCollectionsFromTemplate
creates any missing template collection before it seeds items), recorded on the
task trail rather than papered over with a test that proves nothing.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-20 13:26:05 -04:00
xarmian de96cce900 fix(items,server,web): reserved metadata survives a move; referential metadata travels only within its context (BUG-2674) (#1165)
* fix(items,server): reserved metadata survives a move, and dropped fields are reported (BUG-2674)

Moving an item destroyed its implementation notes, decision log and linked-PR
metadata. Well-formed data, on a routine documented operation, silently, with a
success message.

Reproduced before the fix: a note written through `pad item note` — correct
shape, visible on every surface — was gone after `pad item move`, leaving
fields as `{"status":"new"}`.

## Why it happened

items.MigrateFields drops every key absent from the TARGET schema. The reserved
keys — implementation_notes, decision_log, github_pr, convention — are system
metadata that NO collection schema declares; each renders from its own dedicated
surface rather than as a generic field. So they are absent from every targetDefs
and were dropped on every move.

That blindness is structural, not incidental: any code path reasoning about
fields BY CONSULTING A SCHEMA cannot see these keys. It is the shared root of
this bug and of BUG-2627, where the CLI types a --field value by schema lookup
and these keys fall through to a raw string.

## The enumeration comes first, deliberately

Before this there were four constants and exactly ONE non-test consumer treating
them as a set — an inline || chain in a CLI display path. Naming the set inline
again here would have created the SECOND hand-maintained list, which is the
generator pattern behind both bugs reproduced inside its own fix: the next
reserved field lands in the constants, gets wired into whichever surface
prompted it, and silently misses the other.

So models.IsReservedItemField is now the single place that knows, MigrateFields
consults it, and the CLI's || chain is converted to it — the only way it is
provably THE list rather than A list. (formatChangeValue keeps its per-key
switch: it needs to know WHICH reserved key it has, to say "notes" vs "entries",
not whether the key is reserved.)

`convention` is IN the set, settled with evidence rather than by the principle
alone: 35 of 36 conventions in a live workspace do not store the key at all, and
the one that does holds a blob that is a redundant mirror of the alias keys
beside it. No user types a `convention` object — ApplyItemConventionMetadata
writes it, via library activation and the web form. System-stamped.

## Contract

System-minted non-referential data carries; anything dropped is reported.

PLAN-2357 DR-17 settled the analogous case — tags carry because "there is no
workspace-scoped foreign key to break, so dropping them would lose information
for no safety reason". These are the same shape: inert JSON with nothing that
could dangle in a destination. The plan's carry list simply never considered
them, so there was no deliberate semantics to defer to. DR-17's own heading is
"None of this may be silent."

## The reporting half

MigrateResult.Dropped has always existed and the single-move handler has always
thrown it away, so the only record of a field disappearing was the field being
gone. It now rides the move's audit metadata — not the response body, which is
the bare item and would break every consumer, and the activity timeline is where
someone asking "what happened to my item" looks. Joined into one string because
that map is map[string]string and a raw array renders as a Go map literal in the
timeline (BUG-2628).

## Verified

Unit: reserved keys carry with their payload INTACT (asserted on the value, not
merely the key — a carry that re-encoded or zeroed it would pass a presence
check), and bypass schema matching entirely, so a target declaring
`implementation_notes` as `text` cannot coerce them. Mutants run: guard removed
-> both new tests fail; carried-but-also-reported-dropped -> the not-dropped
assertion fails; carry-everything -> the control leg fails alongside three
pre-existing tests.

Live, against a server built from this branch: the note survives the move
byte-identical, and the move's activity metadata carries
`dropped_fields: "priority, status"` for the values the target schema genuinely
has no home for.

## Known scope limit

The BULK move path still discards its Dropped list — a reporting gap only, since
the carry-through lives in MigrateFields and bulk inherits it. Threading the list
out crosses two function boundaries whose signatures serve every bulk operation,
so it is a refactor of the bulk dispatch's return contract rather than a line.
Filed as BUG-2683 rather than smuggled in here.

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

* fix(server,web): close the four gaps Codex round 1 found on the carry-through (BUG-2674)

Round 1 raised no P1 and four P2s. Three were real defects introduced or exposed
by the carry-through; one was a genuine overclaim in the previous commit. All
four closed here, each mutation-verified rather than asserted.

## A schema may no longer declare a reserved key

MigrateFields carries these keys by identity, but every caller then validates
against the target schema — and ValidateFieldsDetailed iterates schema.Fields,
so it DOES see a declared key. A target declaring implementation_notes as `text`
would receive the carried array and reject it, turning a move that previously
destroyed the notes into one that fails outright. That is a worse failure than
the one being fixed: loud, but it blocks an operation that used to work.

The gate already existed — validateNoReservedFieldKeys, with its
grandfathering — and listed only parent/plan. The four metadata keys join it,
sourced from models.ReservedItemFieldKeys() so the two lists cannot drift.
Forbidding the declaration is the honest fix; coercing the value, or skipping
validation for a key the schema genuinely declares, would be guessing at which
meaning the author wanted.

The web's RESERVED_FIELD_KEYS gains the same four, preserving the existing
deliberate asymmetry (the client lowercases and is therefore stricter than the
server's exact match) so the UI steers authors away before the 400.

## The copy preflight no longer under-reports

`carried` is built by walking the DESTINATION SCHEMA, and these keys are declared
by no schema anywhere — so after the carry-through they appeared in NEITHER
bucket. A copy of an item whose content is its notes would report "nothing
carries over" while in fact retaining them. Before the carry-through they at
least showed under `dropped`, accurately. Reporting in neither is a regression
in the preflight's honesty, which is the same defect class as the move that
reported nothing.

They are now appended to `carried` after the schema-ordered entries, marked
`type: "system"` with a rendered label since they have no author-supplied one.
The bucket's doc comment says so: a client must no longer assume every `carried`
entry resolves to a destination FieldDef.

## The audit report now reaches a human

The previous commit claimed the activity timeline is where someone asks "what
happened to my item" — true, and the timeline renderer ignored the key, so the
report existed only for API and CLI consumers. Stored-but-invisible is not
reported. TimelineActivityCard renders the dropped keys on a move.

## Test aliasing

The "untouched" assertions compared the result against the SAME objects passed
in, so an in-place mutation would change both sides and DeepEqual would stay
true. The expectations are now independent deep copies — the only thing that
makes "untouched" mean untouched.

## Mutants, each run

Preflight pass removed -> the carried assertion fails. Timeline block disabled
-> the render assertion fails. Timeline action guard dropped -> the non-move
negative leg fails (a presence-only test would have passed it). Reserved-set
helper returning everything -> the IsReservedItemField control leg fails.

## Not fixed here

Codex's remaining observation — that a cross-workspace copy now carries
github_pr into a workspace whose repository it does not describe, and leaves a
convention blob detectable on an item outside the conventions collection — is a
product question about what a copy MEANS, not a defect in this mechanism. Raised
for a ruling rather than decided inside a bug fix.

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

* fix(items,server): referential system metadata travels only within its context (BUG-2674)

Lead ruling on the copy-semantics fork Codex round 1 raised. It does not add an
exception to the carry rule — it applies the qualifier the rule already had.

The contract was "system-minted NON-REFERENTIAL data carries". github_pr is
referential: it names a repository that is a property of the SOURCE workspace's
project, and it hydrates into code_context and renders as a live PR link. Carried
into another workspace that link is a false statement about the destination's
project, not preserved information. implementation_notes and decision_log
describe the item's own history and are true wherever the item is.

So the rule stays one sentence: non-referential system data carries everywhere;
referential system data carries only where its referent's context still holds.

## Scope is a required argument

MigrateFields takes items.MigrateScope. Required rather than defaulted because
BOTH wrong answers lose something: SameWorkspace on a cross-workspace copy
carries a PR link into a workspace it does not describe, and CrossWorkspace on
an ordinary move DROPS metadata from an item whose repo context never changed. A
caller that must name its scope cannot pick one by omission.

The two move handlers pass SameWorkspace as a property of the endpoint, not a
guess — a move changes an item's COLLECTION and cannot change its workspace.

The copy and its preflight COMPUTE it by comparing workspace ids rather than
assuming cross-workspace, because that endpoint accepts a target_workspace equal
to the source; hardcoding would drop a github_pr from a same-workspace duplicate.
Both sides use the same helper, or the preview promises a carry the copy drops —
the DR-6 divergence the shared endpoint exists to prevent.

## The drop is reported, with a reason that explains itself

PLAN-2357 DR-17: "None of this may be silent." It would be perverse to
reintroduce a silent drop inside this fix's own new branch.

The preflight reports it as `referent_not_portable` rather than the generic
`no_target_field`. That generic reason would be actively misleading here: no
schema declares these keys ANYWHERE, so "the destination has no such field" is
equally true of the source and explains nothing about why the value is being
left behind.

## Verified

Mutants run: scope ignored (always carry) -> the cross-workspace leg fails;
generic reason on the preflight drop -> the reason assertion fails. The
same-workspace leg and the non-referential-sibling leg are what stop an
implementation that ignores scope in EITHER direction from passing — each half
alone is satisfiable by a constant.

Gates re-run for THIS commit: lint 0 · go test ./... 0 · make test-pg 0 (3282).
Web gates NOT re-run and not claimed: this commit touches no web file (the web
half of BUG-2674 shipped in 82577a74 and is unchanged here).

## Noted, not fixed

handlers_items_copy_preflight.go already documents the same defect class for
RELATION fields — a same-named relation carries a SOURCE-workspace item id
across workspaces and is reported as a clean carry — and says the fix "belongs
in MigrateFields, for both callers at once". MigrateScope is now the mechanism
that comment asks for, but wiring relation fields through it is a separate
change with its own semantics to settle.

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

* fix(items,server): close Codex round 2 — grandfathered schemas, stale drop reports, scope coverage (BUG-2674)

Round 2 raised no P1 and three P2s plus a nit. All four were real; two are
defects in round 1's own fixes.

## Grandfathered schemas that already declare a reserved key

Round 1 added the four metadata keys to validateNoReservedFieldKeys, which stops
the collision being CREATED — and that gate deliberately GRANDFATHERS schemas
that already have one. I did not follow through: such a FieldDef still reached
ValidateFieldsDetailed, met the system-owned array MigrateFields hands through
by identity, and rejected it. A collection whose only sin is a field name
someone was once allowed to pick would fail every move and copy.

ValidateFieldsDetailed now skips reserved keys outright. That is not "ignoring
validation": these values have no user-authored schema to validate against, by
design — the schema entry is the anomaly, not the value. ValidateFields inherits
it through the same call.

This also closes the second half of the same finding: the preflight could report
one key in BOTH needs_value and carried, because the issue came from validating
a key the carried-append also emits. No issue, no collision.

## Dropped reports that were no longer true

MigrateFields computes Dropped BEFORE overrides merge and before defaults are
injected, so a key it lists may have been supplied moments later. Both the move
audit (which I added in this branch) and the preflight's dropped bucket reported
those anyway — claiming "we discarded your due_date" about an item that HAS a
due_date.

That is worse than the silence it replaced: silence at least does not send
someone hunting for data sitting on the item, and a report that cries loss over
visible data teaches the reader to distrust the channel. items.StillDropped
filters against the FINAL map so the report is true at the moment it is written.

## Scope coverage

attachments_copy_plan_test models a copy from workspace A into B and passed
SameWorkspace — the wrong scope stated confidently in a test whose whole subject
is a cross-workspace copy. It came from the bulk edit that threaded the argument
through, which picked a value rather than reading each fixture.

And nothing proved the MUTATING copy honours scope at all, so a call site
passing the wrong one — precisely the mistake a required argument exists to
prevent — would have shipped green. TestCopyEndpoint_ReferentialMetadataTravels-
OnlyWithinItsWorkspace covers both directions end to end. Mutant run: the store
call site pinned to SameWorkspace now fails the cross-workspace leg.

## The nit was an overclaim, so it is fixed in the code

38fa8fec said the copy and its preflight "use the same helper". They did not —
the helper lived in the server package and the store duplicated the comparison
inline, which is how a preview and its copy drift apart. items.ScopeFor now
lives in the package that defines the type and both call it.

Gates: lint 0 (after a gofmt fix lint caught) · go test ./... 0 ·
make test-pg 0 (3283). No web file touched; web gates not re-run.

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

* fix(items,server): move the validation skip to the right altitude, and finish the drop-report fix (BUG-2674)

Codex round 3, no P1, two P2s. Both say round 2's fixes were applied at the
wrong altitude — correct in the case in front of me, wrong for the callers I
did not enumerate.

## The validation skip was global; the problem is local

Round 2 made ValidateFieldsDetailed skip reserved keys. That validator is shared
with create, full update, artifact import and every bulk path — none of which
migrate anything. On a GRANDFATHERED schema (one that already declared a
reserved key before the round-1 gate), those paths genuinely did validate the
key, and the skip stopped them: arbitrary junk could be written into
implementation_notes through create, while fields_patch kept rejecting it via
ValidatePartialFields. Full and partial updates disagreeing about the same key
is a worse bug than the one I was fixing.

Reverted. items.SchemaForMigratedFields strips reserved FieldDefs from the
schema used to validate the OUTPUT of a migration, and only the four migration
and copy sites call it. Create and update keep enforcing the declaration,
because on those paths the user really is authoring that key.

## StillDropped reached two of three surfaces

The move audit and the preflight were filtered; the MUTATING copy was not.
migrateCopyFields returned the raw pre-override list and the 201 response
exposes it as warnings.dropped_fields — so one request could report the key
carried in the preview, PERSIST it, and still call it dropped in the copy's own
response. Three surfaces, two answers.

## And StillDropped's own test was too weak

Presence is not the test — present-and-non-nil is. The move path writes
overrides straight into the map including a nil, where the copy path deletes the
key, so `{"due_date": null}` on a move left the key present carrying nothing.
Treating that as restored suppresses a REAL drop, which is the silent loss this
change exists to end.

## A mutant survived, and the fixture was why

`out.Fields = schema.Fields[:0]` + appends mutates the caller's backing array.
The first version of the input-not-mutated assertion passed it twice: once
because it checked length (Go passes the struct by value, so the caller's slice
HEADER survives), and again after fixing that, because the reserved key was LAST
in the fixture — the one surviving field was written back into the slot it
already occupied. With the reserved key FIRST the corruption lands in slot 0 and
the mutant dies. Recorded in the test, because the next person writing a
"does not mutate its input" assertion in Go will reach for len() too.

## Comment accuracy

The reserved-set doc claimed callers "inherit additions without edits". True for
membership tests, false for the three places that need something a set cannot
supply — referentialItemFieldKeys, reservedFieldLabel, and the web's separate
RESERVED_FIELD_KEYS. Now listed, with the test that fires as the reminder. The
collections-handler comment described only parent/plan and now says it covers
two unrelated groups.

Gates: lint 0 · go test ./... 0 · make test-pg 0 (3285). No web file touched.

## Flagged, not fixed

The preflight labels a destination DEFAULT as from:"migrated" when the source
had the key but migration dropped it — origin is keyed on presence in the source
map, not on where the final value came from. Pre-existing and untouched by this
branch; filed separately rather than folded in.

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

* fix(items,server): close Codex round 4 — grandfathered defaults, override holes, duplicate carried entries (BUG-2674)

Round 4, no P1, three P2s. All three are the same case I kept half-fixing: a
GRANDFATHERED schema that declares a reserved key.

## Reserved declarations were still live in the defaults pass

MigrateFields carried reserved keys by identity but then ran the target schema's
defaults/required loop over them unchanged. A legacy Default was injected into
system metadata as though a user had authored it, and a legacy Required produced
a migration ERROR — which bulk move rejects on BEFORE reaching the
stripped-schema validation. So a legacy target requiring implementation_notes
failed bulk move while single move and copy succeeded: same key, same item, two
answers depending on which button was pressed.

## Overrides were a hole straight through the rule

A field override naming a reserved key was merged and then validated against the
STRIPPED schema — i.e. not validated at all. Two consequences, the second worse
than the first:

  - arbitrary junk could be written into implementation_notes / decision_log,
    bypassing the append guard BUG-2627 exists to enforce;
  - on a cross-workspace copy, an override could reintroduce the github_pr that
    MigrateFields had just dropped for leaving its workspace — defeating the
    scope rule by the simplest available route.

The copy paths now gate overrides against the stripped schema, so a reserved key
is undeclared there by construction and takes the existing malformed_override
refusal. The MOVE path had no declared-key gate at all and gets a dedicated one
(items.ReservedOverrideKeys). Refused rather than silently dropped: a caller who
asked for a value and got an item without it has no way to tell.

## The preflight emitted reserved keys twice

The carried walk iterated the raw target schema, so a grandfathered declaration
was emitted there AND appended again by the reserved pass. The existing
preflight/copy parity helper collapses carried entries into a map, so it could
not see it — a check that de-duplicates before comparing cannot detect
duplication. The walk now uses the stripped schema.

## Two mutants survived, and both were the test's fault

- The defaults fix had no test at all. Written after the fact, it fails on the
  unfixed code on both halves (injected default, spurious required error).
- The override test passed with the stripping REMOVED, because the ordinary
  destination does not declare github_pr — so UndeclaredOverrideKeys refuses it
  either way. Only a schema that DECLARES the key distinguishes the two
  implementations. The grandfathered fixture added for that fails the mutant
  with the PR link visibly written onto the copy.

Also added the falsy-value legs to StillDropped (false / 0 / "" are
restorations, not absences — a truthiness filter would report them lost) and
drove SchemaForMigratedFields off the canonical set so a mutant stripping only
implementation_notes fails.

Gates: lint 0 · go test ./... 0 · make test-pg 0 (3289). No web file touched.

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

* docs(items): correct the scope claim on ReservedOverrideKeys (BUG-2674)

Codex round 5. The previous commit message said reserved keys are refused "on
any path". True only for FIELD-OVERRIDE maps — the same-workspace move, the copy
preflight and the mutating copy. An ordinary `fields` / `fields_patch` map still
reaches them from the CLI, MCP, the web editor, artifact import, and Pad's own
note / decision / convention / GitHub writers, which is by design for the system
writers and a pre-existing exposure for the rest.

The doc comment now says which paths it covers and, more importantly, what it is
NOT — a general write gate. That distinction is the kind a future reader would
otherwise take on trust from the function name.

Round 5 was asked a different question than rounds 1-4: not "what is wrong with
this diff" but "enumerate every path that could meet a declared reserved key,
and is this approach right at all". It found ~10 further latent sites (create,
full and partial update, artifact import, bulk status/priority, terminal
options, unique_scope, computed, the web field editor, search, share
presentation) — all PRE-EXISTING, none regressions from this branch, and all in
the same grandfathered-schema case rounds 3, 4 and 5 kept surfacing.

They are filed as BUG-2685 with the full map rather than patched here. Four
rounds each finding another site is evidence about the DESIGN — reserved
metadata living in the generic fields blob means every schema-aware consumer has
to remember a special rule — and that is TASK-2657's territory, not a bigger
version of this bug. This branch's scope was: a move destroys system metadata.
That is fixed, tested and mutation-verified.

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

* docs(mcp,cli): disclose the move/copy metadata rules where the ACTOR reads them; ToolSurfaceVersion 0.22 (BUG-2674)

Caught by the pre-push step my own record exists for: I had documented this
change carefully in commit messages, the PR body and the item trail — every one
of them read by a human REVIEWING the work — and not at all in the artifacts read
by the agent or operator ACTING on it. That is the same miss twice before, both
times in this exact file.

`field` is accepted for `pad_item.action=move` (catalog_item.go), so the refusal
this branch adds is a limit an MCP agent will hit. It now says so in the param's
own description and in instructions.md, which is the text agents receive at
handshake. CLAUDE.md's `pad item move` and `pad item copy` blocks — the operator-
facing reference — gain the carry rules and the github_pr exception.

## ToolSurfaceVersion 0.21 -> 0.22

BEHAVIOR bump on the v0.9 / v0.16 / v0.17 grounds: no tool, action enum or param
SHAPE changed, but two things an agent can observe did.

A move used to DESTROY implementation_notes / decision_log / github_pr /
convention, silently, and now preserves them; drops of ordinary fields are
reported in the move's activity entry instead of vanishing. And a `field` setter
naming one of those keys answers `malformed_override` instead of writing it —
a write that was never legitimate, since it bypassed BUG-2627's append guard and
could reintroduce a github_pr the migration had just dropped.

Compat posture stated deliberately: a caller passing such a setter today gets a
400 where it previously got a silent corrupt write. Relying on the old behaviour
is relying on a defect — the same reading v0.17 took for the fields-blob
shadowing.

The bump was not free, which is the point: TestInstructionsMDVersionMatchesTool-
Surface and TestReadmeVersionMatchesToolSurface both went red and forced the two
other surfaces to be updated. That is the enforcement working — a version
constant nobody could change without visiting every place it is published.

Gates re-run for this commit: lint 0 · go test ./... 0 · make test-pg 0 (3289).
CI was already 7/7 green on f6775bcb; pushing this restarts it, which is the
correct trade against shipping agent-facing docs that describe the old behaviour.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-19 16:53:03 -04:00
xarmian d24df55670 refactor(web): delete unmounted components carrying real logic (TASK-2632) (#1163)
A one-shot sweep, not a standing process. Two dead components had been found
incidentally in one week, each discovered only because someone was about to
change behaviour it appeared to depend on -- VersionHistory during BUG-2608
(its apparent liveness would have blocked a default history limit) and
EditorToolbar before it. Retired UI left in-tree costs every future reader who
greps for a component, finds a plausible implementation, and reasons about
behaviour nobody mounts; it also silently constrains fixes.

Instrument, two passes over all 110 components under web/src/lib/components:

1. Plain substring grep of each basename across web/src + web/e2e. Four
   zero-hit. This pass counts COMMENTS as liveness, so it under-reports
   deadness -- conservative in the safe direction.
2. Import/mount-only regex (a from-import of the .svelte path, a dynamic
   import of it, or a <Name element). Six zero-hit; the two extras were
   exactly the comment-shadowed cases pass 1 could not see.

Controls: a known-live component (BacklinksPanel) resolves to its single
consumer under both passes; each of the six candidates then took a repo-wide
plain grep with no include filters, and every surviving hit was read. Pass 2's
one known blind spot -- a component referenced only by vi.mock(path) -- was
checked by enumerating every vi.mock target ending in .svelte; all are
.svelte.ts store/service modules except CommentEditor, which is independently
imported. None of the six is in that set.

Deleted (six dead, two cascade orphans):

- activity/ActivityFeed.svelte -- a live /activity route page and
  TimelineActivityCard both exist; neither touches it.
- charts/LineChart.svelte and charts/layers/Lines.svelte -- from the TASK-1632
  LayerCake library; only BarChart reached the insights pages. Lines had
  exactly one consumer (LineChart), so it falls with it. AxisX/AxisY stay:
  shared with BarChart.
- charts/Sparkline.svelte (TASK-1638) -- its only repo-wide reference was a
  prose comment recording that PLAN-1542 chose not to show it. Zero mounts.
- editor/MermaidRenderer.svelte -- superseded by the MermaidCodeBlock NodeView
  in Editor.svelte, which owns the render queue, toggle and error state.
- versions/VersionHistory.svelte -- the BUG-2608 find. The live path is
  ItemTimeline to TimelineVersionCard to DiffView; DiffView stays.
- attachments/fixtures/LightboxStub.svelte and fixtures/lightboxStub.ts -- a
  pair that referenced only each other. Their last consumer was removed by the
  TASK-2489 atomic cutover, so they were orphaned rather than born dead.

Nothing was reclassified live-but-obscure, so no import-site comments were
owed. Two docs updated so no artifact points at a deleted file: the web README
component tree drops the activity/ line, and the UserOverviewTab comment now
says the Sparkline component was deleted here and is recoverable from history,
rather than leaving a dangling decision record.

Git history is the archive; anything worth resurrecting is one revert away.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-19 11:39:39 -04:00
xarmian e36be90f05 fix(web): order-and-merge contract for the localIndex cache — tombstones, durable retag overlay, equal-seq merge (PLAN-2636 unit 2, BUG-2633/2634/2635) (#1157)
The persisted localIndex cache decided writes with a bare seq compare that
(a) had no memory of hard deletes, (b) could not arbitrate out-of-band
fields (collection_slug), and (c) blind-accepted at equal seq. Those are one
gap seen three ways. This lands the single write policy + read overlay the
PLAN-2636 unit-2 design checkpoint specifies.

- itemRowMerge.ts (new, pure): resolveRowWrite(stored, tombstone, incoming)
  replaces the boolean shouldWriteRow at both persistence call sites —
  tombstone gate (2633) -> seq guard + no-seq asymmetry (unchanged from 2609)
  -> equal-seq MERGE (2635) -> preserveProjectionMetadata on write. The two
  projection helpers move here verbatim from localIndex.svelte.ts so the IDB
  layer can share them without importing the Svelte store; RAM stays
  bit-identical (pinned by localIndexUnparented.svelte.test.ts).

- Tombstones (2633): new `tombstones` object store, IDB format v1->v2 (row
  shape unchanged, LOCAL_INDEX_SCHEMA_VERSION stays 3). Hard removals in
  persistDelta stamp deletedAtSeq=cursor; persistRemovals stamps the persisted
  meta.sync cursor, or — when no sync has run — the removed row's own seq, so
  an upsert-then-remove before the first sync is still un-resurrectable (codex
  F1). raiseTombstone never lowers an existing stamp, so an out-of-order
  cross-tab eviction can't weaken the gate (codex F4). A stale/seq-less
  snapshot behind the stamp can't resurrect; a strictly-newer write supersedes
  and clears the tombstone. persistReplace clears the store. Old build opening
  a v2 DB gets a VersionError -> memory-only degrade.

- Durable retag overlay (2634): persistRetag also upserts {key:'retags', map}
  in the meta store; hydrate reapplies it to matching rows (by collection_id)
  after the read, so a rename survives a racing older-slug delta and a reload.
  persistReplace drops the key (server rows carry live slugs — BUG-2601).

Tests: itemRowMerge.test.ts (pure decision matrix), localIndexPersistence
.unit2.idb.test.ts (tombstone/overlay/cross-tab/migration outcomes through
fake-indexeddb), harness raw-readers for tombstones + retags. Every regression
mutation-verified discriminating — including the codex-round-1 fixes: neuter
tombstone gate / blind-accept equal seq / skip overlay-apply / drop preserve /
break the shared helper / skip pre-sync tombstone (F1) / blind tombstone
overwrite (F4) / ignore overlay membership (F5) — each reddens exactly the
matching test across RAM and persistence. shouldWriteRow removed (superseded);
its sibling test repointed.

Gates: npm run test 1730 passed (98 files); npm run check 0 errors;
check:tiptap-pins OK; vite build clean. WEB-ONLY, zero Go, no dep churn
(fake-indexeddb already on main from unit 1).

Codex round 1: F1/F4/F5 fixed above. F2 (persistReplace clears tombstones for
omitted ids — pre-existing cross-tab window, self-healing) and F3 (durable
overlay can revert a newer authoritative slug after a missed rename SSE; no
local disambiguator — collection_slug is out-of-band) are lead-accepted as
documented residuals (F2 at persistReplace, F3 at the hydrate overlay-apply
site, each with its trigger + heal paths). True F3 disambiguator would be
server-side collection-slug versioning; ruling on the PLAN-2636 trail.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-18 17:42:29 -04:00
xarmian b759898d5b test(web): fake-indexeddb harness for the localIndex persistence layer (PLAN-2636 unit 1) (#1156)
The localIndex IndexedDB persistence layer ran as a no-op under vitest —
there is no IndexedDB in the node test environment, so `isSupported()`
returned false and every persist/hydrate call short-circuited. The entire
layer, and BUG-2609's seq-guard fix, shipped on live-browser evidence runs
only (the #1148 finding). This adds the harness that makes it testable, the
prerequisite for unit 2's order-and-merge regression matrix.

- fake-indexeddb dev dependency (exact-pinned).
- A dedicated `idb` vitest project (glob `*.idb.test.ts`, node env) whose
  setup installs fake-indexeddb's globals and a fresh IDBFactory per test.
  It self-disables when the dep can't be resolved — mirroring the jsdom
  project — so a symlinked worktree without it keeps `npm run test` green,
  and CI activates it once installed. The idb glob is excluded from the node
  project so the persistence layer can't no-op there and pass vacuously.
- Harness helpers unit 2 builds on: a second cross-tab connection to the
  same database (2635), a v1-database seed + higher-format-version reopen +
  downgrade VersionError (the v1→v2 migration exercise), a fresh-module
  loader that clears the connection cache, and raw ground-truth reads.
  `harnessDbName` mirrors the module's `dbName` exactly, pinned by a test so
  a drift can't make assertions read an empty sibling database.
- BUG-2609's evidence run is ported as a deterministic sequential regression:
  a newer delta commits its atomic rows+cursor transaction, then a stale
  older-seq snapshot lands last and is refused (IDB serializes overlapping
  transactions, so no interleaving control is needed). Plus a raw
  serialization characterization pinning that platform guarantee.

No production code changes.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-18 15:20:13 -04:00
xarmian b5f0cd3963 feat(web,server): render embedded image attachments on share pages via a variants-only byte endpoint (BUG-2389 2b, TASK-2637) (#1153)
Merged by lead on accepted infra cancellation, Dave-approved in chat (day-44). Evidence basis: E2E tests demonstrably pass at pinned SHA d3c8234e — run 1: 191 passed (3.7m), job cancelled by CI/Nix twin-run concurrency race; run 2: 190 passed + 1 flaky (pane-controller, PLAN-2154's known flake, unrelated to this diff), job cancelled by the 10-minute cap after the flaky retry; run 3: rerun expired inside terminal-cancelled parent at 38s, no test signal. All six code-testing checks green (Go, Go-PG, Web, Nix, Smoke×2). The gate defect is filed as BUG-2645 (cap breachable by one flaky retry + twin-run race); the fix ships as its own reviewed workflow unit. Lead spot-check of the diff and full pin inventory: TASK-2637 trail.
2026-08-18 12:26:00 -04:00
xarmian 482815aa58 feat(web): composer + quick actions target armed sessions with honest counts (PLAN-2613 S4, TASK-2619) (#1151)
* feat(web): push composer + quick actions target armed sessions with honest counts (PLAN-2613 S4, TASK-2619)

Presentation truthfulness (D3): only an armed session receives a push (the
server filters delivery to armed sessions), so connected is not the same as
accepting. Surfaces that decided push-vs-copy or enabled Send on the raw
connected count would fire-and-forget into a connected-but-unarmed session and
lose the instruction.

- LiveSession TS type gains `armed` (S1 shipped it server-side; the web type
  had not caught up).
- PushToAgentDialog: counts and targets the ACCEPTING (armed) subset. The
  presence line shows the split honestly — "M sessions accepting pushes
  (of N connected)", and the "N connected, 0 accepting pushes" empty state with
  /pad:connect enable instructions rather than hiding connected-but-unarmed
  sessions behind a bare zero. Send is gated on accepting > 0; the picker offers
  only armed sessions; broadcast reaches only accepting sessions (server-
  filtered). Degrades to N == M once the S3 rollout completes.
- QuickActionsMenu: routes push-vs-copy on the accepting count, so a quick
  action copies (never silently pushes) when nothing is accepting; tagline and
  the shared dispatch copy say "accepting pushes", not "connected".

No new server state — this consumes S1's per-session armed bit from
GET /api/v1/sessions.

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

* fix(web): quick-actions tagline shows the accepting-of-connected split (Codex R2)

The menu tagline showed only the accepting count, hiding connected-but-unarmed
sessions — the split D3 wants for quick actions as much as for the composer.
PushPresence now carries `connected` alongside the accepting `count` (routing
still keys on accepting), and the tagline renders "M accepting session(s)
(of N connected)", plus the "N connected, 0 accepting pushes — run /pad:connect
to enable" state instead of collapsing to a bare zero.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-18 01:07:35 -04:00
xarmian 2322fb273f fix(web): give the IDB write path the seq guard RAM already had (BUG-2609) (#1148)
* fix(web): give the IDB write path the seq guard RAM already had (BUG-2609)

`upsert` and `applyRetag` hand persistUpserts a snapshot taken from RAM and do
not await it. An SSE delta for the same row can commit its own atomic
rows+cursor transaction in between, after which the older snapshot lands LAST:
IndexedDB then holds a pre-delta row while the persisted cursor sits past that
delta. Warm boot hydrates the stale row and `/items-changes?since=cursor` never
returns it again, so it stays stale until the item happens to change. RAM is
unaffected — single-threaded and already seq-guarded by mergeRow — which is why
this only ever showed up as a warm-boot regression.

The fix is the guard localIndex has had in RAM all along, applied at the layer
that lacked it: a read-modify-write inside the transaction, comparing against
what is STORED at write time rather than what was in RAM at snapshot time. Both
writers get it, because the race runs in both directions — a delta must not
overwrite a row that is already newer in the cache either.

Three boundary decisions, each with a reason rather than a default:

  - EQUAL seq still writes. RAM merges same-seq projections before persisting,
    so the incoming row IS the merged one; refusing it would drop that merge
    and leave the cache behind RAM with no seq difference left to correct it.
  - A MISSING seq on either side writes. Absence is not evidence of being
    older, and refusing would silently disable the cache for any row the server
    has not stamped.
  - seq 0 is a value, not a blank. A falsy check here would let a stale row
    through, so the comparison tests for undefined explicitly.

Worth stating because the outcome looks lossy and is not: `applyRetag` rewrites
collection_slug WITHOUT bumping seq, so a delta at a higher seq now SKIPS the
retag write. That leaves IDB agreeing with the persisted cursor while its slug
lags RAM — which self-heals through the sync-pass reconcile (BUG-2601). What it
replaces does not self-heal: a row behind the cursor is invisible to delta sync
by construction.

VERIFIED IN A REAL BROWSER, because the failure mode of getting this wrong is
silent. Awaiting inside an idb transaction risks the transaction auto-closing
mid-loop, after which the remaining puts throw into a swallowed catch and the
cache quietly stops being written — worse than the bug. In-repo precedent said
it was safe (hydrate already awaits a get and keeps using the same tx), and a
live run confirmed it: 2625 rows persisted, all seq-stamped, cursor advanced,
zero page errors. The instrument was checked against a control build whose
guard refuses every write — 0 rows, cursor still advanced, which is the
cursor-ahead-of-rows divergence this bug is about, so the check demonstrably
reads the thing it claims to.

The decision itself is covered by unit tests, each mutation-verified against
the specific assertion written for it (always-allow, strict-greater-than, and a
falsy seq check each fail only their own cases).

* fix(web): route retags through a field-level write, and stop overclaiming the guard (BUG-2609)

Codex round 1. The P2 is a correction to my own commit message, and the more
important of the two findings.

I wrote that a retag write skipped by the seq guard "self-heals through the
sync-pass reconcile (BUG-2601)". It does not, and the code says so where I
should have read it: `applyRetag` does not bump seq, a collection rename
touches no items so no item delta ever re-stamps them, and localIndex's own
comment states pendingRetags is "not persisted (the window it guards is within
a single session)". A persisted slug that loses its rename stays wrong across
reloads with nothing left to correct it — so my guard would have turned a
last-write-wins race into a permanent staleness.

That is the second time today I stated a mechanism I had not read, and this
one made it into a commit message as the justification for shipping.

Fixed by design rather than by rewording. A retag is a FIELD-LEVEL intent:
"these rows are in a collection that got renamed". Expressing it as a whole-row
put makes a second claim — that every other field still matches a RAM snapshot
— and it is that claim the guard has to refuse. persistRetag reads each row
inside the transaction and changes only collection_slug, so the newer row's
fields survive AND the rename lands. Rows absent from the cache are skipped:
nothing to rename, and inserting a snapshot there would resurrect rows a delta
may have removed.

Codex's P1 — a delayed snapshot can resurrect a HARD-deleted row, because a
deleted row leaves no `existing` for the guard to compare against — is real,
pre-existing (a blind put resurrected it too), and not fixed here. Refusing it
needs a tombstone carrying the seq it was removed at, i.e. an IDB schema change
plus a version bump. Filed as BUG-2633.

The guard's doc no longer implies it covers either case. It now names both
exclusions, which is what it should have said before Codex had to ask.

Re-verified live after the redesign: 2625 rows persisted, all seq-stamped,
cursor advanced, zero page errors.

* docs+test: name the direction persistRetag does NOT close, and stop the tests implying coverage (BUG-2609)

Codex round 2, three findings.

P1 is real and I had only closed half the problem. persistRetag stops a late
RETAG from being refused, but a delta captured BEFORE the rename can commit
after it, whole-row put an older collection_slug at a NEWER seq, and pass the
guard legitimately. The root shape is that collection_slug is OUT-OF-BAND
relative to the item's own version — it changes without seq changing — so no
seq comparison can arbitrate it in either direction. Pre-existing (a blind put
lost the same race) and filed as BUG-2634 with the fix that actually closes it:
make the rename DURABLE, persisting the retag intent and reapplying it on
hydrate, the way localIndex already does in RAM with pendingRetags. That is a
different mechanism from this bug's guard, which is why it is filed rather than
folded in. The guard's doc now names the direction it does not cover instead of
implying it covers renames outright.

The other two are accuracy defects in what I wrote. A comment still said
`applyRetag` hands snapshots to persistUpserts, two paragraphs above the
exclusion list explaining that it no longer does. And the new test block read
as persistRetag coverage while testing only shouldWriteRow — a no-op
persistRetag would pass this entire file. It now says so in its name and its
doc, and the harness note says the same about the live browser run: that was
evidence the write path commits, not a regression test, and it will not run
again on its own.

Three of this bug's four review findings have been about my prose claiming more
than the code does, which is worth noting as the pattern rather than fixing
quietly a third time.

* docs: name the equal-seq residual where the decision is made (BUG-2609)

Codex round 3. Equal seq is permitted, and the justification I gave — RAM has
already merged the projection — holds WITHIN a tab and not across them: a tab
that never had the row in RAM can persist an unmerged snapshot over another
tab's merged one at the same seq.

Measured the reachability rather than accepting the report as stated. After
this branch, persistUpserts has exactly one caller, and `localIndex.upsert`
returns early when `next.seq <= existing.seq` — so it cannot persist an
equal-seq row when it HAS the row in RAM. The regression needs two tabs, the
writing tab lacking the row, and the other having merged a same-seq projection.
Narrow, and strictly better than before this branch, when a blind put regressed
rows at ANY seq.

Not fixed here because accept-or-refuse is the wrong axis: refusing would
mirror the bug onto persistDelta, where a re-delivered same-seq row carrying
computed projection fields would be skipped. What equal seq actually wants is a
MERGE, which this layer does not have and which is a deliberate layering
decision (persistence importing projection semantics). Filed as BUG-2635.

That makes three residuals from this one bug, and they are one family rather
than three coincidences — the persisted cache has no total order and no merge
semantics of its own. BUG-2633 (deletes have no seq to compare against),
BUG-2634 (collection_slug is out-of-band relative to seq), BUG-2635 (equal seq
needs merging). Each has a distinct fix, but they touch the same two functions
and share the same testing prerequisite, so 2635 carries the cross-links and
suggests triaging them as a batch — one pass giving the cache real
order-and-merge semantics beats three patches.

* fix(web): refuse a seq-less snapshot over a stamped row (BUG-2609)

Codex round 4, and this one was a real defect in my guard that my own test
enshrined.

I treated a missing seq symmetrically — "absence is not evidence of being
older, so write it" — which is true in one direction and wrong in the other.
The optimistic reorder path deliberately clears `seq` so the row bypasses
localIndex's RAM guard and the drag paints immediately (TASK-1357, verified at
the call site). Persisting that copy is incidental to the intent, and my rule
let a delayed seq-less snapshot overwrite an authoritative row that had already
landed at a real seq.

The result is worse than an ordinary stale row: the persisted row then has NO
seq at all, so neither this guard nor the RAM guard can order it on the next
warm boot, while the cursor sits past the delta that would have corrected it.

Now asymmetric, with the reasoning in place: an incoming row carrying ordering
evidence beats a stored row with none; two unstamped rows have nothing to
arbitrate; a stored STAMPED row refuses a snapshot that has no seq. Refusing
costs the reorder nothing — RAM still shows the optimistic order, the
authoritative response persists with a real seq moments later, and the cursor
has not advanced past that response, so a warm boot in between simply refetches
it.

The test that asserted the old behaviour has been replaced rather than
adjusted, and the mutation restoring the symmetric rule now fails only the new
case. Re-verified live after the change: 2625 rows persisted, all seq-stamped,
cursor advanced, zero page errors.

Distinct from the three filed residuals — this is the guard being wrong, not a
gap it deliberately leaves.

* fix(web): re-check collection membership inside the retag transaction (BUG-2609)

Codex round 5, a defect in the function I added last round. persistRetag read
each row by id and applied the renamed collection's slug without re-checking
that the row was still IN that collection. A row that moves between the RAM
retag and this transaction would then be persisted with a collection_id and a
collection_slug that disagree — behind the cursor, so no delta repairs it.

The irony is the point: persistRetag exists BECAUSE trusting a RAM snapshot at
write time is unsafe, and it went on trusting the snapshot's collection
membership. Re-reading the row was never the whole fix; re-checking what the
row says is.

The per-row decision is extracted as shouldApplyRetag so it is reachable by
tests at all — the transaction itself is not, in a harness with no IndexedDB —
and it now covers three refusals with a reason each: a row that moved
(mismatched id/slug persisted behind the cursor), a row already carrying the
new slug (idempotence), and an absent row (inserting one would resurrect it
behind the cursor, the BUG-2633 shape).

Each mutation-verified against its own assertion: dropping the membership check
fails only the moved-row case, and inserting absent rows fails only the
resurrection case. Re-verified live: 2625 rows persisted, all seq-stamped,
cursor advanced, zero page errors.

* docs: correct two claims this file made about itself (BUG-2609)

Codex round 7, both accuracy defects in my own prose after five rounds of
edits to it.

shouldWriteRow's opening line still said it returns false ONLY for strictly
older rows, which round 4 made untrue — it also refuses a seq-less row over a
stamped one. The paragraph below described the exception correctly, so the
summary contradicted its own body. Now states both cases up front.

The test file asserted "jsdom has no IndexedDB", and this file does not run in
jsdom: a plain .test.ts belongs to vitest's `node` project, per vitest.config.
The conclusion happened to hold — Node has no indexedDB global either — which
is the part worth flagging: right answer, wrong stated reason, and I had not
opened the config before writing it. It also claimed every function in the
module is a no-op under vitest, which is false of the exported decision
helpers being tested two screens above; that distinction is the whole reason
they were extracted.

Fixing the first attempt at this broke the file, and the cause is worth
recording: the glob I wrote to name the vitest project contained the character
pair that ENDS a block comment, so the doc terminated early and the rest parsed
as code — 19 type errors and a suite that reported "no tests". Same family as
backticks inside a double-quoted shell string: content carrying a delimiter the
surrounding syntax acts on. Rephrased to avoid the sequence rather than
escaping around it.

* docs: name the abort trigger at persistRetag, folded into BUG-2634 (BUG-2609)

Codex round 9. persistRetag is best-effort like everything in this module, so
an aborted transaction (quota, eviction, tab freeze) loses the rename outright
— and a lost rename does not self-heal for the same reason it cannot be
reordered: no item delta re-stamps those rows, and pendingRetags is in-memory.

Not a fourth filing. It is BUG-2634 reached by failure instead of by racing,
and the fix already proposed there — persist the retag INTENT and reapply it on
hydrate — closes both, because a recorded intent survives a failed write as
readily as a lost race. The real defect is that a rename is persisted as an
EFFECT with no durable intent, which makes it losable by anything.

Recorded on BUG-2634 so whoever takes it builds for both triggers (an
ordering-only fix would leave the abort case open and look complete), and noted
at persistRetag so a reader there meets the limit rather than inferring the
function is reliable.
2026-08-17 21:10:17 -04:00
xarmian 6f16003199 fix: surface implementation notes + decision log in the item timeline (BUG-2301) (#1144)
* fix(server): merge implementation notes + decision log into the item timeline (BUG-2301)

`pad item note` and `pad item decide` have written structured entries since
c61f4cda, and 998716ae deleted their renderer the next day as collateral of
the unified-timeline PR. The write paths kept working on CLI and MCP, so the
entries accumulated with no read surface outside `pad item show`.

Surface them as two more timeline kinds rather than rebuilding a separate
renderer: the endpoint already merges comments, activities and versions under
cursor pagination, and notes/decisions carry the same timestamp/actor/body
shape the merge handles.

They differ from the other three kinds in one way that matters. They are
elements of the item's fields blob, not rows, so they arrive whole on the
already-resolved item instead of through a cursor query. Without an explicit
filter they would therefore repeat on every page, so structuredTimelineEntries
applies the same (created_at, id) predicate the SQL sources use.

The blob is also hand-writable, which makes three shapes representable that a
table would not, all covered:

  - no created_at: anchored at the item's own creation instant, the earliest
    moment the entry could have existed. A zero-time fallback would render as
    1970 and sort below everything real.
  - no id: positional fallback, keeping the sort total and the cursor stable.
  - not an array at all: models.ExtractItem* already returns nil, so it
    contributes nothing. One live docapp item is in exactly this state
    (double-encoded JSON string) — filed as BUG-2627, a different defect.

Every guard here was mutation-verified: dropping the merge, neutering the
cursor predicate, and removing each of the two fallbacks in turn each fail
the tests that cover them. That pass also caught a vacuous assertion in the
actor test, which now counts the entries it asserts on (CONVE-12).

Frontend wiring follows in the next commit; the kinds are invisible until
ItemDetail's visibleKinds whitelist admits them.

* fix(web): render note + decision timeline entries and admit them to the tab filter (BUG-2301)

The server half is inert without this. `visibleKinds` is a WHITELIST with one
live call site, so a kind ItemDetail does not list renders on NEITHER tab — a
perfectly merged feed and an empty Activity tab, which is how this feature
shipped invisible the first time.

Two halves, both needed and both covered by mutation-verified tests:

  - ItemTimeline gains render branches for the `note` and `decision` kinds
    plus their rail dots. Without a branch the entry falls through the {#if}
    chain and draws an empty rail.
  - ItemDetail admits both to the Activity set. They belong there rather than
    with Versions: they record things that happened to the item, not restore
    points.

One TimelineStructuredCard serves both kinds. They share a shape — headline,
optional body, actor, timestamp — and differ in label, accent and weight, so a
variant keeps them from drifting the way two near-identical components would.
A decision carries the heavier treatment: it is the thing you go back looking
for.

Body text renders as plain text with `white-space: pre-wrap`, never through
the markdown pipeline, because that is what the writers produce — `pad item
note --details` and `--stdin` take raw text. A test pins that markup in an
entry stays inert.

The actor label reads the entry's self-declared `created_by`. That field lives
inside the item's fields blob and no server stamps it (BUG-2542), so the label
reports a claim, not a verified author; the comment in the card says so.

* docs(skill): document `pad item note` / `pad item decide` now that they have a read surface (BUG-2301)

The bug's own measurement found 185 notes and 33 decisions across seven
workspaces written by people and agents who found these commands on their
own — nothing in the skill, no convention, no playbook ever mentioned them.
That was defensible while the entries were invisible outside `pad item show`;
it is not once they render in the item timeline.

Flag names verified against the built binary's `--help` rather than the
source, since the skill is what an agent acts on.

* test(server): assert timeline paging is exactly-once, on both drivers (BUG-2301)

The single-page cursor assertions cover the predicate but not the property
that matters to a reader scrolling an item: every entry appears exactly once
across the whole feed. A too-loose predicate repeats the in-blob entries on
every page and a too-tight one drops them at a boundary, and neither is
visible from one page.

Run on Postgres as well as SQLite because there is a genuine seam here: the
structured entries are filtered in Go against a parsed time.Time while the
comment/activity/version sources are filtered in SQL against a formatted
string, and this endpoint has a Postgres-specific paging history (BUG-1086,
the \xff sentinel). Portability is asserted, not assumed.

The Postgres leg asserts the driver before doing anything, so it cannot pass
by silently re-running SQLite — verified both ways: it SKIPs without
PAD_TEST_POSTGRES_URL and PASSes with it. Mutation-verified too: neutering
the cursor predicate fails the leg on both drivers.

* fix(server): align the structured cursor with the SQL predicate and make blob ids unique (BUG-2301)

Three defects from Codex round 2, all in the cursor path this change added.

1. The "g" sentinel split the two kinds on their first letter. When a client
   sends `before` without `before_id` the handler substitutes "g" — an upper
   bound whose whole job is to KEEP same-second entries, and which does that
   only because every lowercase-hex UUID character sorts below it. Structured
   ids are not UUIDs: `note-…` sorts above "g" and `decision-…` below, so
   comparing against it literally dropped every note at the cursor instant
   while keeping every decision. The handler now says whether beforeID is
   synthetic, and the filter honours what the sentinel MEANS.

2. Two comparison spaces met on one page boundary. The SQL sources format the
   cursor to whole-second RFC3339 text and compare against a text column,
   while this filter compared full-precision time.Time. A structured entry can
   carry sub-second precision — a hand-written created_at, or the item's own
   createdAt standing in for an absent one — so the two predicates could
   resolve the same boundary differently and drop or repeat entries around it.
   Both sides now compare formatted whole-second text; the seam is removed
   rather than compensated for.

3. Duplicate ids were trusted. Nothing validates them on write, and a repeat
   is not cosmetic: it collides in the client's keyed {#each} (a hard render
   error), the client's loadMore dedupes by id and would drop the older entry,
   and the cursor cannot page past two entries it cannot tell apart. Repeats
   now take the same positional fallback an absent id takes, in one map shared
   across both kinds since they land in one merged stream.

Round 2's fourth item was a test gap rather than a defect, and is closed here
too: the paged walk asserted only that the three structured ids appeared once,
so a boundary mismatch that repeated a COMMENT or a VERSION would have passed.
It now asserts no entry of any kind repeats.

Round 1's only finding — structured entries do not live-refresh because the SSE
filter excludes item_updated — is DECLINED and recorded on the item. That
exclusion predates this diff and is deliberate (refreshing on every content
save caused visible shakiness and rate-limit errors); version entries already
carry the identical staleness, and these kinds have no web writer at all, so
no user acts and waits on one.

Each fix has its own negative control: removing the sentinel branch, reverting
to full-precision comparison, and trusting raw ids each fail exactly the test
that covers them.

* fix(server): truncate structured entry timestamps to the shared whole-second space (BUG-2301)

Codex round 3, P1 — and a correction to the previous commit, which fixed the
comparison and left the value itself alone. Filtering in formatted whole-second
text made the PREDICATE agree with SQL, but the entry still carried
full-precision time, so two paths stayed wrong:

  - the merge sorts on TimelineEntry.CreatedAt, so a fractional structured
    entry interleaved against same-second rows by a component those rows do
    not have, in an order the SQL ORDER BY cannot reproduce.
  - the client echoes the last entry's created_at back as the next page's
    `before`, where the store formats it down to the second. A cursor of
    10:00:00.5 becomes 10:00:00Z and EXCLUDES same-second rows that were still
    owed — silent data loss in comments and versions, sources this change
    never touched.

Truncating where the entry is built puts it in the same space as every other
source for all three purposes at once, which is what the fix should have been
the first time. Covered end to end: a fractional entry at a page boundary must
not cost a same-second row on the next page.

Round 3's P2 (a `has_more` heuristic that can stay true without pagination
progress when an over-fetched source is emptied by dedup) is NOT addressed
here. It is pre-existing — the heuristic and the discards it counts on both
predate this branch, and structured entries are never discarded by
buildTimeline, so this diff neither causes nor worsens it. I have not
reproduced it; recorded on the item for triage rather than asserted as real.

* fix: render payload-less structured entries, and make the fractional-boundary test actually discriminate (BUG-2301)

Codex round 4, all three findings.

The important one is against my own test. The fractional-timestamp regression
test walked two structured entries and no SQL-sourced row, so the data loss it
was named for could not occur in it — and confirmed by mutation: with the
truncation removed it still passed. Reworking it to include a real comment at
the note's own second was not enough either, and the reason is worth writing
down: the cursor's second term is the id, the SQL sources keep same-second rows
with `id < before_id`, and a realistic `note-<nanos>` id sorts ABOVE every
lowercase-hex UUID. The sibling row was rescued by the tie-break no matter what
the timestamp did. With an id below the UUID space the loss is reachable, and
the test now fails on the unfixed code by dropping the comment outright.

Two rounds of a correct-looking test that could not fail. The tell both times
was the same: I checked that the test passed with the fix and not that it
failed without it, on a fixture I had reasoned about rather than run.

Also:
  - A structured entry whose payload is missing now still renders its card.
    Guarding the branch on the payload left the rail dot and connector drawn
    beside nothing, which reads as a broken render rather than a thin entry;
    the card was already null-safe. Covered, and mutation-verified by
    restoring the guard.
  - Corrected a comment that claimed a zero-time fallback renders as 1970. Go's
    zero time is year 1, not the Unix epoch.

* docs(models): qualify the timeline paging claim to the static-dataset case (BUG-2301)

Codex round 5. The finding — the five sources are read at five instants with
no shared snapshot, so a concurrent note write can land between the item
resolve and the activity query and put one page briefly out of step — is real
but is NOT fixed here, deliberately:

  - It is the endpoint's existing shape, not something the structured kinds
    introduce. Comments, activities and versions were already three separate
    reads at three instants; this adds a fourth source, not a fourth class of
    problem.
  - Nothing is durably lost. The blob is authoritative and the very next fetch
    is consistent; the window is a request's worth of milliseconds on a
    read-only feed.
  - Every fix that would actually close it (a shared snapshot or a read
    transaction spanning all five sources) is a change to the endpoint's
    contract and the store's API, which is not something to do inside a bug
    fix for a missing renderer.

What IS wrong and is fixed: my own comment claimed paging "behaves identically
for all five" without qualification, and the earlier commit claimed exactly-once
paging flatly. Both are true over a stable dataset and neither said so. That is
the failure mode I keep hitting from the other side — being precise in the
artifact I am editing while an unqualified claim sits where the next maintainer
will actually read it. The type's doc comment now states the limit and says
whose problem it is.

* docs(web): record why the structured kinds inherit the timeline's SSE staleness (BUG-2301)

Codex raised the live-refresh gap twice and it was declined twice, which is
itself the signal that the reasoning belonged in the code rather than in a
review thread. The exclusion's comment now says what the two structured kinds
inherit from it and why admitting item_updated would be a bad trade.

* docs(server): name the cursor sentinel's UUID assumption at the sentinel (BUG-2301)

Lead's pre-merge ask, and the existing text was worse than merely silent: case
3 stated that the "g" sentinel keeps same-second entries, full stop. That is
true only for ids from the lowercase-hex UUID alphabet. Anything sorting above
"g" is dropped at the cursor instant instead, and a source whose ids straddle
it is split in half on their first character — which is exactly what happened
to `note-…` and `decision-…` here.

So the assumption is now named where someone adding a non-UUID id will read
it, rather than only in the helper that already works around it. An unqualified
claim at the point of use is the failure mode I keep meeting from both sides;
this is the same fix as qualifying the paging comment two commits ago.

Comments only — no behaviour change.
2026-08-17 12:58:09 -04:00
xarmian cc26288794 fix(web): share pages render attachment refs as honest placeholders (BUG-2389) (#1135)
The public share route (/s/{token}) rendered item content with a bare
marked() call, so pad-attachment: references fell through as broken
<img src="pad-attachment:..."> tags and dead links. Two halves:

1. CommentThread.svelte is deleted outright — grep proved it was
   unmounted dead code (its only reference was a prose mention in
   ItemDetail.svelte), so its half of the bug resolves by deletion
   rather than by fixing a component nothing renders.

2. The share route now renders through a new opt-in wrapper,
   renderMarkedWithAttachments(), which threads an AttachmentRenderContext
   into the existing marked renderer hooks. With a null resolver and the
   new renderAttachmentUnavailable() placeholder, every ref becomes an
   honest "Attachments aren't available on shared pages yet" chip —
   deliberately NOT the "missing or has been deleted" wording, because
   the attachment exists; the share surface just cannot serve its bytes.
   Sanitization is unchanged: the wrapper returns unsanitized HTML and
   the share page keeps its single DOMPurify pass.

The `missing` hook is a parameter (default: renderAttachmentMissing) so
authed surfaces keep their existing wording, and the wrapper clears the
module context in a finally block so bare marked() callers are
unaffected (pinned by test).

The token-scoped byte endpoint that would serve real images on share
pages (2b) is deliberately NOT built here — it adds a new
unauthenticated ACL surface and is tracked separately pending approval.
A real resolver through the same wrapper is the plug-in point (pinned
by test).

Tests: markdown.shareAttachments.test.ts (6 unit legs incl. bare-marked
opt-in control and context-clearing) and
bug-2389-share-attachment-placeholder.spec.ts (e2e: real upload → item
ref → item share link → anonymous visit; verified failing on the
pre-fix build).

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-17 05:19:25 -04:00
xarmian 2521e3e1c7 fix(web): portal the pane action-bar menus — anchored panels clipped against the pane's scroll container (BUG-2610) (#1132)
* fix(web): portal the pane action-bar menus — anchored panels clipped against the pane's scroll container (BUG-2610)

In split view the item pane is an overflow-y:auto scroll container,
which computes overflow-x:auto too — the quick-actions (⚡) and ⋯
menus were ANCHORED panels inside it, and a right-aligned panel
opening from the pane's action bar extends left past the pane's edge,
so the container clipped it mid-text (Dave's screenshots: 'age
actions' for 'Manage actions', truncated tagline).

Both menus now use the Menu component's portal mode — built precisely
to escape overflow containment (fixed coords portaled to <body>,
viewport clamping, flip-when-cramped, scroll dismissal), and already
the mode of every board-card menu. Widths cover each menu's content
(QA: the 230px qa-body min-width + chrome; ⋯: the longest row).

Regression e2e uses a PAINT-level oracle — clipping doesn't shrink
getBoundingClientRect, so elementFromPoint just inside the panel's
left edge must resolve to the panel; verified failing on the anchored
control build with the exact reported symptom, plus a geometry
precondition so the probe can't pass vacuously. Existing e2e + unit
lookups that scoped menu rows under .item-pane / the master column
are page-scoped now (the portaled panel lives in <body>; only one
menu is ever open, and the scoped trigger click is what ties it to
its column).

* fixup: scope portal scroll-dismiss to anchor-moving containers — any-scroll dismissal closed pane menus under live SSE churn (found via parallel e2e instability vs a clean control build)

* fixup: codex round 1 — route nav-key bail includes portaled [role=menu] (pre-existing leak for board menus too), stopPropagation on handled menu keys, exempt-aware scroll dismiss, per-menu e2e geometry preconditions

* fixup: page-scope the two graph-drawer menu lookups in pane-content-link-anchors (codex round 2)
2026-08-17 03:24:24 -04:00
xarmian fbea9484d6 fix(web): source-identity guards on the SSE non-sync listeners (BUG-2611) (#1131)
close() does not retract already-queued event tasks, so on a fast
workspace switch a torn-down EventSource's queued events could fire
after the next workspace's source existed. BUG-2540 guarded onopen /
onerror / 'connected'; the remaining listeners had no guard, so
workspace A's stale events dispatched into workspace B's callbacks —
spurious sync passes and cross-workspace item events fanned onto B's
BroadcastChannel, and the sharp member: a stale 'unauthorized' closed
B's LIVE EventSource, flipped the status indicator, and cleared
currentWorkspace over A's auth state, with nothing reconnecting until
a navigation.

Same one-line guard on all four (sync_required, items_bulk_updated,
unauthorized, the ITEM_EVENTS loop); unauthorized additionally closes
its OWN source rather than whatever eventSource currently points at —
the guard has just proven they are the same, and the old shape is what
made the stale path destructive.

Unit harness (BUG-2540's stubbed-EventSource pattern): fast A→B
switch, fire each event type on the torn-down source — zero dispatch
into B — with a live-source control arm per leg so a guard silencing
both cannot pass. All four legs verified failing on the unguarded
build.
2026-08-17 02:07:07 -04:00
xarmian 54526c5b33 fix(web): route ItemDetail collection writes through a semantic adopt gate (BUG-2602) (#1128)
* fix(web): route ItemDetail collection writes through a semantic adopt gate (BUG-2602)

Seven sites assigned ItemDetail's collection snapshot under fences that
ordered STARTS, and loadData's cross-collection escape hatch admitted
any generation-stale write that fetched a different collection — so a
loadData continuation spanning a cross-collection MOVE restored the
SOURCE collection over the freshly adopted TARGET (the live item,
itemGen-fenced, kept the move: the pane rendered the item against the
wrong collection's schema).

All writes now route through adoptCollection, backed by the pure
shouldAdoptCollection decision: (1) a snapshot disagreeing with the
LIVE item's collection_id is vetoed regardless of freshness — id, not
slug, so renames still apply and a reused slug can't satisfy it (this
also closes a latent foreign-write in the SSE collection_updated
refresh, which fetched by slug); (2) same-collection refreshes keep
newest-started-wins; (3) the legitimate cross-collection correction
the old hatch existed for still lands when the live item agrees.

On the veto path, an embedded pane whose collection was still null
(fresh mount — refreshCollectionIfMoved's !collection guard skips
there too) converges on the live item's collection instead of being
left schema-less (adoptOrConvergeToLiveCollection); non-embedded
masters stay route-authoritative per the existing policy.

e2e reproduces the filed race deterministically (route-hold on the
realColl fetch, API move mid-hold, release): the control build renders
the moved item against SrcMarkerField's schema verbatim; the fixed
build converges on the target's.

* fixup: codex round 1 — post-converge myGen re-checks, schema-less error surfacing, pre-fetch convergeGen, hard collection_id oracle in e2e

* fixup: codex round 3 — empty-string collection_id normalizes to no-anchor, else-branch schema-less surfacing, itemGen re-check before singleton claims

* fixup: two stale comments (codex round 5 docs)
2026-08-17 00:27:34 -04:00