mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 02:23:46 +00:00
6f16003199
* fix(server): merge implementation notes + decision log into the item timeline (BUG-2301) `pad item note` and `pad item decide` have written structured entries sincec61f4cda, and998716aedeleted their renderer the next day as collateral of the unified-timeline PR. The write paths kept working on CLI and MCP, so the entries accumulated with no read surface outside `pad item show`. Surface them as two more timeline kinds rather than rebuilding a separate renderer: the endpoint already merges comments, activities and versions under cursor pagination, and notes/decisions carry the same timestamp/actor/body shape the merge handles. They differ from the other three kinds in one way that matters. They are elements of the item's fields blob, not rows, so they arrive whole on the already-resolved item instead of through a cursor query. Without an explicit filter they would therefore repeat on every page, so structuredTimelineEntries applies the same (created_at, id) predicate the SQL sources use. The blob is also hand-writable, which makes three shapes representable that a table would not, all covered: - no created_at: anchored at the item's own creation instant, the earliest moment the entry could have existed. A zero-time fallback would render as 1970 and sort below everything real. - no id: positional fallback, keeping the sort total and the cursor stable. - not an array at all: models.ExtractItem* already returns nil, so it contributes nothing. One live docapp item is in exactly this state (double-encoded JSON string) — filed as BUG-2627, a different defect. Every guard here was mutation-verified: dropping the merge, neutering the cursor predicate, and removing each of the two fallbacks in turn each fail the tests that cover them. That pass also caught a vacuous assertion in the actor test, which now counts the entries it asserts on (CONVE-12). Frontend wiring follows in the next commit; the kinds are invisible until ItemDetail's visibleKinds whitelist admits them. * fix(web): render note + decision timeline entries and admit them to the tab filter (BUG-2301) The server half is inert without this. `visibleKinds` is a WHITELIST with one live call site, so a kind ItemDetail does not list renders on NEITHER tab — a perfectly merged feed and an empty Activity tab, which is how this feature shipped invisible the first time. Two halves, both needed and both covered by mutation-verified tests: - ItemTimeline gains render branches for the `note` and `decision` kinds plus their rail dots. Without a branch the entry falls through the {#if} chain and draws an empty rail. - ItemDetail admits both to the Activity set. They belong there rather than with Versions: they record things that happened to the item, not restore points. One TimelineStructuredCard serves both kinds. They share a shape — headline, optional body, actor, timestamp — and differ in label, accent and weight, so a variant keeps them from drifting the way two near-identical components would. A decision carries the heavier treatment: it is the thing you go back looking for. Body text renders as plain text with `white-space: pre-wrap`, never through the markdown pipeline, because that is what the writers produce — `pad item note --details` and `--stdin` take raw text. A test pins that markup in an entry stays inert. The actor label reads the entry's self-declared `created_by`. That field lives inside the item's fields blob and no server stamps it (BUG-2542), so the label reports a claim, not a verified author; the comment in the card says so. * docs(skill): document `pad item note` / `pad item decide` now that they have a read surface (BUG-2301) The bug's own measurement found 185 notes and 33 decisions across seven workspaces written by people and agents who found these commands on their own — nothing in the skill, no convention, no playbook ever mentioned them. That was defensible while the entries were invisible outside `pad item show`; it is not once they render in the item timeline. Flag names verified against the built binary's `--help` rather than the source, since the skill is what an agent acts on. * test(server): assert timeline paging is exactly-once, on both drivers (BUG-2301) The single-page cursor assertions cover the predicate but not the property that matters to a reader scrolling an item: every entry appears exactly once across the whole feed. A too-loose predicate repeats the in-blob entries on every page and a too-tight one drops them at a boundary, and neither is visible from one page. Run on Postgres as well as SQLite because there is a genuine seam here: the structured entries are filtered in Go against a parsed time.Time while the comment/activity/version sources are filtered in SQL against a formatted string, and this endpoint has a Postgres-specific paging history (BUG-1086, the \xff sentinel). Portability is asserted, not assumed. The Postgres leg asserts the driver before doing anything, so it cannot pass by silently re-running SQLite — verified both ways: it SKIPs without PAD_TEST_POSTGRES_URL and PASSes with it. Mutation-verified too: neutering the cursor predicate fails the leg on both drivers. * fix(server): align the structured cursor with the SQL predicate and make blob ids unique (BUG-2301) Three defects from Codex round 2, all in the cursor path this change added. 1. The "g" sentinel split the two kinds on their first letter. When a client sends `before` without `before_id` the handler substitutes "g" — an upper bound whose whole job is to KEEP same-second entries, and which does that only because every lowercase-hex UUID character sorts below it. Structured ids are not UUIDs: `note-…` sorts above "g" and `decision-…` below, so comparing against it literally dropped every note at the cursor instant while keeping every decision. The handler now says whether beforeID is synthetic, and the filter honours what the sentinel MEANS. 2. Two comparison spaces met on one page boundary. The SQL sources format the cursor to whole-second RFC3339 text and compare against a text column, while this filter compared full-precision time.Time. A structured entry can carry sub-second precision — a hand-written created_at, or the item's own createdAt standing in for an absent one — so the two predicates could resolve the same boundary differently and drop or repeat entries around it. Both sides now compare formatted whole-second text; the seam is removed rather than compensated for. 3. Duplicate ids were trusted. Nothing validates them on write, and a repeat is not cosmetic: it collides in the client's keyed {#each} (a hard render error), the client's loadMore dedupes by id and would drop the older entry, and the cursor cannot page past two entries it cannot tell apart. Repeats now take the same positional fallback an absent id takes, in one map shared across both kinds since they land in one merged stream. Round 2's fourth item was a test gap rather than a defect, and is closed here too: the paged walk asserted only that the three structured ids appeared once, so a boundary mismatch that repeated a COMMENT or a VERSION would have passed. It now asserts no entry of any kind repeats. Round 1's only finding — structured entries do not live-refresh because the SSE filter excludes item_updated — is DECLINED and recorded on the item. That exclusion predates this diff and is deliberate (refreshing on every content save caused visible shakiness and rate-limit errors); version entries already carry the identical staleness, and these kinds have no web writer at all, so no user acts and waits on one. Each fix has its own negative control: removing the sentinel branch, reverting to full-precision comparison, and trusting raw ids each fail exactly the test that covers them. * fix(server): truncate structured entry timestamps to the shared whole-second space (BUG-2301) Codex round 3, P1 — and a correction to the previous commit, which fixed the comparison and left the value itself alone. Filtering in formatted whole-second text made the PREDICATE agree with SQL, but the entry still carried full-precision time, so two paths stayed wrong: - the merge sorts on TimelineEntry.CreatedAt, so a fractional structured entry interleaved against same-second rows by a component those rows do not have, in an order the SQL ORDER BY cannot reproduce. - the client echoes the last entry's created_at back as the next page's `before`, where the store formats it down to the second. A cursor of 10:00:00.5 becomes 10:00:00Z and EXCLUDES same-second rows that were still owed — silent data loss in comments and versions, sources this change never touched. Truncating where the entry is built puts it in the same space as every other source for all three purposes at once, which is what the fix should have been the first time. Covered end to end: a fractional entry at a page boundary must not cost a same-second row on the next page. Round 3's P2 (a `has_more` heuristic that can stay true without pagination progress when an over-fetched source is emptied by dedup) is NOT addressed here. It is pre-existing — the heuristic and the discards it counts on both predate this branch, and structured entries are never discarded by buildTimeline, so this diff neither causes nor worsens it. I have not reproduced it; recorded on the item for triage rather than asserted as real. * fix: render payload-less structured entries, and make the fractional-boundary test actually discriminate (BUG-2301) Codex round 4, all three findings. The important one is against my own test. The fractional-timestamp regression test walked two structured entries and no SQL-sourced row, so the data loss it was named for could not occur in it — and confirmed by mutation: with the truncation removed it still passed. Reworking it to include a real comment at the note's own second was not enough either, and the reason is worth writing down: the cursor's second term is the id, the SQL sources keep same-second rows with `id < before_id`, and a realistic `note-<nanos>` id sorts ABOVE every lowercase-hex UUID. The sibling row was rescued by the tie-break no matter what the timestamp did. With an id below the UUID space the loss is reachable, and the test now fails on the unfixed code by dropping the comment outright. Two rounds of a correct-looking test that could not fail. The tell both times was the same: I checked that the test passed with the fix and not that it failed without it, on a fixture I had reasoned about rather than run. Also: - A structured entry whose payload is missing now still renders its card. Guarding the branch on the payload left the rail dot and connector drawn beside nothing, which reads as a broken render rather than a thin entry; the card was already null-safe. Covered, and mutation-verified by restoring the guard. - Corrected a comment that claimed a zero-time fallback renders as 1970. Go's zero time is year 1, not the Unix epoch. * docs(models): qualify the timeline paging claim to the static-dataset case (BUG-2301) Codex round 5. The finding — the five sources are read at five instants with no shared snapshot, so a concurrent note write can land between the item resolve and the activity query and put one page briefly out of step — is real but is NOT fixed here, deliberately: - It is the endpoint's existing shape, not something the structured kinds introduce. Comments, activities and versions were already three separate reads at three instants; this adds a fourth source, not a fourth class of problem. - Nothing is durably lost. The blob is authoritative and the very next fetch is consistent; the window is a request's worth of milliseconds on a read-only feed. - Every fix that would actually close it (a shared snapshot or a read transaction spanning all five sources) is a change to the endpoint's contract and the store's API, which is not something to do inside a bug fix for a missing renderer. What IS wrong and is fixed: my own comment claimed paging "behaves identically for all five" without qualification, and the earlier commit claimed exactly-once paging flatly. Both are true over a stable dataset and neither said so. That is the failure mode I keep hitting from the other side — being precise in the artifact I am editing while an unqualified claim sits where the next maintainer will actually read it. The type's doc comment now states the limit and says whose problem it is. * docs(web): record why the structured kinds inherit the timeline's SSE staleness (BUG-2301) Codex raised the live-refresh gap twice and it was declined twice, which is itself the signal that the reasoning belonged in the code rather than in a review thread. The exclusion's comment now says what the two structured kinds inherit from it and why admitting item_updated would be a bad trade. * docs(server): name the cursor sentinel's UUID assumption at the sentinel (BUG-2301) Lead's pre-merge ask, and the existing text was worse than merely silent: case 3 stated that the "g" sentinel keeps same-second entries, full stop. That is true only for ids from the lowercase-hex UUID alphabet. Anything sorting above "g" is dropped at the cursor instant instead, and a source whose ids straddle it is split in half on their first character — which is exactly what happened to `note-…` and `decision-…` here. So the assumption is now named where someone adding a non-UUID id will read it, rather than only in the helper that already works around it. An unqualified claim at the point of use is the failure mode I keep meeting from both sides; this is the same fix as qualifying the paging comment two commits ago. Comments only — no behaviour change.