mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-12 16:08:11 +00:00
74c786d5906787ddef0b10ecdc7dd15d23a4d2ed
755 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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> |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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. |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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.
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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)
|
||
|
|
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. |
||
|
|
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) |