mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
31c8d426dd
* 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
Pad Web UI
SvelteKit 2 + Svelte 5 frontend for Pad, compiled to static files and embedded into the Go binary.
Development
npm install
npm run dev # Dev server at localhost:5173 (proxies API to localhost:7777)
npm run build # Production build to build/
npm run check # Type checking with svelte-check
When developing, run the Go backend separately with make dev from the project root.
Building for Production
Do not build in isolation. Always use make build from the project root — this builds the web frontend, then compiles the Go binary with the build output embedded via //go:embed.
Stack
- Svelte 5 with runes (
$state,$derived,$effect) - SvelteKit 2 with
adapter-static(SPA mode) - Tiptap block editor with markdown round-trip
- svelte-dnd-action for drag-and-drop in board/list views
- SSE for real-time updates
- TypeScript throughout
Structure
src/
routes/ SvelteKit pages
+layout.svelte App shell (sidebar + main)
+page.svelte Landing/redirect
[workspace]/
+page.svelte Dashboard (collections, phases, activity)
+layout.svelte SSE connection per workspace
[collection]/
+page.svelte Collection view (board/list)
[collection]/[item]/
+page.svelte Item detail + editor
conventions/ Purpose-built conventions page
playbooks/ Purpose-built playbooks page
settings/ Workspace settings
lib/
api/client.ts HTTP API client
components/
layout/ Sidebar, navigation
editor/ Tiptap editor, raw markdown editor
fields/ FieldEditor, relation picker
items/ ItemCard, ItemDetail
collections/ BoardView, ListView
common/ StatusBadge, badges, modals
search/ CommandPalette
stores/ Svelte 5 reactive stores
workspace.svelte.ts Workspace state
collections.svelte.ts Collection + item state
ui.svelte.ts Sidebar, mobile state
types/index.ts TypeScript types and constants
app.css Global styles and design tokens