mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 02:53:31 +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.
158 lines
7.3 KiB
Go
158 lines
7.3 KiB
Go
package models
|
|
|
|
import "time"
|
|
|
|
// Item-level actions (existing)
|
|
var ValidActions = []string{
|
|
"created", "updated", "archived", "restored", "moved", "read", "searched",
|
|
}
|
|
|
|
// Audit action constants for auth/admin events
|
|
const (
|
|
ActionLogin = "login"
|
|
ActionLoginFailed = "login_failed"
|
|
ActionLogout = "logout"
|
|
ActionBootstrap = "bootstrap"
|
|
ActionRegister = "register"
|
|
ActionPasswordChanged = "password_changed"
|
|
ActionPasswordReset = "password_reset"
|
|
ActionTokenCreated = "token_created"
|
|
ActionTokenRevoked = "token_revoked"
|
|
ActionTokenRotated = "token_rotated"
|
|
ActionTOTPEnabled = "totp_enabled"
|
|
ActionTOTPDisabled = "totp_disabled"
|
|
ActionMemberInvited = "member_invited"
|
|
ActionMemberRemoved = "member_removed"
|
|
ActionRoleChanged = "role_changed"
|
|
ActionSettingsChanged = "settings_changed"
|
|
ActionOAuthLogin = "oauth_login"
|
|
ActionOAuthLoginFailed = "oauth_login_failed"
|
|
ActionPlanChanged = "plan_changed"
|
|
// ActionPlanOverridesChanged is logged when an admin updates a
|
|
// user's plan_overrides JSON via the admin user-detail page.
|
|
// Surfaces per-user storage / workspace / API-token quota
|
|
// overrides in the audit feed so operators can correlate a
|
|
// mysteriously-allowed upload with the override that enabled it.
|
|
ActionPlanOverridesChanged = "plan_overrides_changed"
|
|
ActionPasswordResetByAdmin = "password_reset_by_admin"
|
|
ActionUserDisabled = "user_disabled"
|
|
ActionUserEnabled = "user_enabled"
|
|
ActionAccountDeleted = "account_deleted"
|
|
// ActionEmailVerified is logged when a user confirms their email address
|
|
// via a verification link (POST /auth/verify-email). PLAN-1933 / TASK-1936.
|
|
ActionEmailVerified = "email_verified"
|
|
// ActionEmailVerifiedByAdmin is logged when an admin force-verifies a
|
|
// user's email from the admin console (DR-7). PLAN-1933 / TASK-1936.
|
|
ActionEmailVerifiedByAdmin = "email_verified_by_admin"
|
|
// ActionSessionIPChanged is logged when a session presents a different
|
|
// client IP than the one recorded at creation. We don't strict-check IP
|
|
// by default (that breaks legitimate geo shifts — VPN toggle, mobile
|
|
// roaming) but surface the change to the audit log for detection. In
|
|
// deployments configured with PAD_IP_CHANGE_ENFORCE=strict the middleware
|
|
// additionally rejects the request.
|
|
ActionSessionIPChanged = "session_ip_changed"
|
|
// ActionSessionUAChanged is logged when a session presents a different
|
|
// User-Agent hash than the one recorded at creation. Like the IP signal
|
|
// this is surfaced to the audit log for detection; in deployments
|
|
// configured with PAD_IP_CHANGE_ENFORCE=strict the middleware additionally
|
|
// revokes the session and rejects the request. The UA hash is stable for
|
|
// the life of a real session (a browser doesn't rewrite its own UA mid-
|
|
// session), so a mismatch is a stronger theft signal than an IP change —
|
|
// which is precisely why UA enforcement carries fewer false positives than
|
|
// IP enforcement. This audit row is only emitted in strict mode; log-only
|
|
// mode keeps the historical slog-only behavior to avoid changing the audit
|
|
// feed for existing self-host users.
|
|
ActionSessionUAChanged = "session_ua_changed"
|
|
// ActionStripeEventUnmarked is logged when /admin/stripe-event-unmark
|
|
// rolls back a row from stripe_processed_events (TASK-736). The
|
|
// endpoint intentionally reopens Stripe retry windows, so a persisted
|
|
// audit trail is required — a compromised cloud_secret could otherwise
|
|
// spam unmarks invisible to the admin /audit-log UI.
|
|
ActionStripeEventUnmarked = "stripe_event_unmarked"
|
|
// ActionPaymentFailedEmailSent is logged when the sidecar triggers the
|
|
// /admin/payment-failed endpoint and pad dispatches a failed-payment
|
|
// notification to the user. Audit trail exists so operators can prove
|
|
// a customer was notified before a dunning-related plan change.
|
|
ActionPaymentFailedEmailSent = "payment_failed_email_sent"
|
|
)
|
|
|
|
type Activity struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id,omitempty"`
|
|
DocumentID string `json:"document_id,omitempty"`
|
|
Action string `json:"action"`
|
|
Actor string `json:"actor"`
|
|
Source string `json:"source"`
|
|
Metadata string `json:"metadata,omitempty"` // JSON
|
|
UserID string `json:"user_id,omitempty"`
|
|
IPAddress string `json:"ip_address,omitempty"`
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
// Enrichment fields — populated by handlers, not stored in DB
|
|
ItemTitle string `json:"item_title,omitempty"`
|
|
ItemSlug string `json:"item_slug,omitempty"`
|
|
ItemRef string `json:"item_ref,omitempty"` // e.g. "BUG-1748" — computed from the referenced item
|
|
CollectionSlug string `json:"collection_slug,omitempty"`
|
|
ActorName string `json:"actor_name,omitempty"`
|
|
}
|
|
|
|
type ActivityListParams struct {
|
|
Action string
|
|
Actor string
|
|
Source string
|
|
// Since, when non-zero, restricts results to activity created on or
|
|
// after this instant (a.created_at >= Since). Applied in the SQL query
|
|
// so LIMIT counts post-filter rows. Used by `pad project activity
|
|
// --since` and the pad_project.activity MCP action.
|
|
Since time.Time
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
// AuditLogParams are query parameters for the audit log endpoint.
|
|
type AuditLogParams struct {
|
|
Action string
|
|
Actor string
|
|
WorkspaceID string
|
|
Days int
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
// TimelineEntry represents a single entry in the unified item timeline.
|
|
// It wraps one of: a comment, an activity, a version, an implementation
|
|
// note, or a decision-log entry.
|
|
//
|
|
// Notes and decisions differ from the other three kinds in where they live:
|
|
// they are elements of the item's own fields blob, not rows in a table, so
|
|
// they arrive already-loaded on the item rather than through a cursor query
|
|
// (BUG-2301). The handler still runs them through the same (created_at, id)
|
|
// cursor predicate the SQL uses, so paging behaves identically for all five —
|
|
// over a STABLE dataset. The five sources are read at five instants with no
|
|
// shared snapshot, so a write landing mid-request can put one page slightly
|
|
// out of step with another (an `updated` activity present while the note that
|
|
// caused it is not, or the reverse). That predates this type and is not
|
|
// specific to the structured kinds — the three SQL sources were already read
|
|
// one after another. Nothing is lost: the blob is authoritative and the next
|
|
// fetch is consistent.
|
|
type TimelineEntry struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"` // "comment", "activity", "version", "note", "decision"
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Actor string `json:"actor"`
|
|
ActorName string `json:"actor_name,omitempty"`
|
|
Source string `json:"source"`
|
|
Comment *Comment `json:"comment,omitempty"`
|
|
Activity *Activity `json:"activity,omitempty"`
|
|
Version *Version `json:"version,omitempty"`
|
|
Note *ItemImplementationNote `json:"note,omitempty"`
|
|
Decision *ItemDecisionLogEntry `json:"decision,omitempty"`
|
|
}
|
|
|
|
// TimelineResponse is the paginated response from the timeline endpoint.
|
|
type TimelineResponse struct {
|
|
Entries []TimelineEntry `json:"entries"`
|
|
HasMore bool `json:"has_more"`
|
|
}
|