Files
pad/internal/models/activity.go
T
xarmian ffc11387e1 feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617) (#2228)
* feat(verify): verification evidence tied to a revision, slice 1 (TASK-3617)

Store, REST and the read-time judgment for per-criterion verdicts:
- item_verifications (SQLite 150, PG 120), append-only, with the NUL
  census classified and trigger file 151 generated.
- internal/verification: criterion hash (whitespace-collapsed), the
  removed / changed / superseded / current state, and the advisory
  possibly_stale hint from a linked PR merged after the verdict.
- POST/GET /items/{ref}/verifications: atomic batch, seq-guarded so a
  criterion hash is stored against the body it came from, via resolved
  in-workspace and visibility-checked, activity "verified", item_updated
  SSE.
- github_pr carries merge_sha / merged_at, fetched by pad github link
  and reconcile.

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

* test(verify): router tests, body-order sort, compress census, CLI merge fields (TASK-3617)

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

* test(verify): close mutant holes M5 (re-spacing) and M8 (merged before the verdict); placeholder folds into missing (TASK-3617)

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

* docs(api): the verifications endpoint (TASK-3617)

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

* fix(verify): r1 findings: the retry re-checks access; a moved item is told from a gone via (TASK-3617)

- F1: when the item moves between the criteria read and the write, the
  retry re-runs visibility and edit permission on the fresh item.
- F2: a zero-row insert re-checks the target in the transaction, so a
  Postgres seq race retries (or 404s) instead of answering unknown_via.
- F3: verificationBeforeInsertHook test seam; tests for one move, an
  item that keeps moving (409), and a grant revoked mid-retry (404).

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

* fix(verify): r2 L1, the retry re-resolves via; gate findings (TASK-3617)

- The retry re-runs the via resolution and its visibility check, the same
  re-check F1 added for the item; TestTASK3617_RetryRechecksVia.
- The state-history test asserted by row position, and one batch's rows
  share a created_at, so their order is by random id: assert by criterion.
- nulscan.go's protected-column count (146: 27 JSON, 119 text).

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

* test(store): the scan-cost pin follows the list, 146 columns (TASK-3617)

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

* chore(verify): drop the comment resolveVia's doc comment superseded (TASK-3617 r3)

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

* fix(verify): r4, verifications travel with a workspace export/import (TASK-3617)

- F1: VerificationExport, exported for the bundle's live items and
  imported with the item and via ids remapped, every other value
  verbatim. Each bundle row is checked before its insert (a refused
  statement on Postgres loses the whole import); a row that fails, or
  whose item did not import, is dropped and counted. Tests: a SQLite round
  trip, the migrate-to-pg path (SQLite export into a Postgres import with
  the slug preserved), and a hostile bundle on both backends.
- F2: docs/api.md says a criterion repeated in one request is
  validation_error, as the code answers.
- Q2: gh's mergedAt zero time is read as not merged.

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

* fix(store): item_verifications is a migrated table (TASK-3617 r4 gate)

The export now carries verifications, so the three lists that name what
`pad db migrate-to-pg` copies must name it too: MigratedTables (the NUL
preflight scans it), its export-section test map, and
migratedRefusalTables (the post-migration write refusal covers it).
Swept by item_reminders across cmd/, internal/ and docs/: those three
are the population.

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

* chore(store): renumber TASK-3617 migrations after #2230 (sqlite 151/152, pg 121)

#2230 took sqlite 150 and pg 120 for decision_jobs.not_before. The
verification table is now sqlite 151 / pg 121, and its NUL trigger file
is 152_nul_invariant_triggers_post151 (regenerated, so the trigger names
carry pad_nul152_). References swept by the old file names and the bare
numbers in this branch's diff.

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

* test(verify): an id that is both a criterion and a placeholder is refused (TASK-3617, lead spot mutant)

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
2026-10-19 10:25:43 -04:00

325 lines
16 KiB
Go

package models
import (
"encoding/json"
"time"
)
// Item-level actions (existing)
var ValidActions = []string{
"created", "updated", "archived", "restored", "moved", "read", "searched",
"verified", // TASK-3617: a batch of acceptance-criteria verdicts
}
// 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"
// ActionMemberInviteDeclined: the invitee declined a pending invitation
// (BUG-2136). The invitation row is deleted; this is its trail.
ActionMemberInviteDeclined = "member_invite_declined"
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"
// ActionAccountClaimed is logged when the owner of an address claims a
// never-verified account registered with it, through the verification
// link (BUG-3382): every credential is reset.
ActionAccountClaimed = "account_claimed"
// ActionAppInstalled is logged when a workspace owner confirms an app
// install and it is provisioned (SPEC-6 U8b, TASK-3397).
ActionAppInstalled = "app_installed"
// ActionAppInstallCodeIssued is logged when an owner issues a new install
// code for an existing install (the lost-redeem recovery, TASK-3397).
ActionAppInstallCodeIssued = "app_install_code_issued"
// ActionAppInstallLifecycle is logged when an owner disables, re-enables,
// rotates or uninstalls an app install (U8c, TASK-3397); the metadata
// names the action and the resulting state.
ActionAppInstallLifecycle = "app_install_lifecycle"
// 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"`
// ViaApp is the app install the write came through, empty for every
// human and agent write (SPEC-6, TASK-3390). It is part of the debounce
// identity, so an app's activity and a human's never merge. Not
// serialized yet: the app's display name is resolved for the API in U6.
ViaApp string `json:"-"`
// 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
// CollectionID, when set, restricts results to activity on items that
// are in this collection now (TASK-2219), in the SQL query so LIMIT and
// the keyset cursor count post-filter rows. Workspace-level rows (no
// item) are excluded under it. Backs the web activity page's filter.
CollectionID string
Limit int
Offset int
// Before and BeforeID are the keyset cursor (BUG-2781): return rows
// strictly after (Before, BeforeID) in the feed's (created_at, id)
// DESCENDING order. The caller takes both from the last row it holds.
// Mutually exclusive with Offset at the HTTP doors.
Before time.Time
BeforeID string
// Visibility, when non-nil, restricts the feed to rows the caller may
// see, in the SQL query so LIMIT and the keyset cursor count only visible
// rows (BUG-3687). Filtering after the LIMIT returned short or empty
// pages to a restricted member, and a short page reads as the end of the
// feed. nil means unrestricted (a cookie-session admin).
Visibility *ActivityVisibility
// ExcludeUnresolvedItems drops rows that name an item which no longer
// exists, for every caller, scoped or not. The feed shows such rows as
// workspace-level; the dashboard never renders them, so it sets this to
// keep them from filling its window (BUG-3687, Codex rounds 1 and 2).
ExcludeUnresolvedItems bool
}
// ActivityVisibility is the caller's read scope for activity rows, as the
// server's visibleCollectionIDs and guestResourceFilter resolve it. A row
// with no item, or whose item no longer exists, is workspace-level and always
// visible. A row on an item is visible when the item's collection is in
// CollectionIDs AND, if the caller holds any item grants, the collection is
// in FullCollectionIDs or the item is in GrantedItemIDs.
type ActivityVisibility struct {
CollectionIDs []string
FullCollectionIDs []string
GrantedItemIDs []string
// ExcludeWorkspaceLevel drops rows with no item, or whose item no longer
// exists. The dashboard sets it for a guest, whom it never shows
// workspace-level activity (member invites, role changes).
ExcludeWorkspaceLevel bool
}
// AuditLogParams are query parameters for the audit log endpoint.
type AuditLogParams struct {
Action string
Actor string
WorkspaceID string
Days int
Limit int
Offset int
// Before and BeforeID: the keyset cursor, as on ActivityListParams.
Before time.Time
BeforeID string
}
// 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"`
// AgentName is set on "comment" entries only, and is DERIVED: it is a
// copy of Comment.AgentName, surfaced at entry level to match the
// actor_name idiom (ActorName is likewise a copy of Comment.Author for
// comment entries), so a client reads entry-level attribution the same
// way for every kind. The nested comment's value is the authoritative
// one — it is what the store's join wrote. Activity entries deliberately
// do NOT get it: their name already lives in Activity.Metadata, and a
// second copy there would be a second source that can drift (TASK-2760,
// lead ruling on the trail).
AgentName string `json:"agent_name,omitempty"`
// CommentOnUpdate is set on a "comment" entry whose comment an item update
// carried (`pad item update --comment`, the update's "updated" activity),
// and false for a comment written on its own, whose linked activity is its
// own "commented" row (BUG-3437). The card's "commented on update" label
// keys on it; it used to key on activity_id, which EVERY comment has.
CommentOnUpdate bool `json:"comment_on_update,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"`
// AutosaveRun is set on a collab-snapshot version entry that stands for a
// collapsed run of autosaves (PLAN-2348 U3): the entry is the run's
// NEWEST row, and this says what the dropped older rows were.
AutosaveRun *AutosaveRun `json:"autosave_run,omitempty"`
}
// AutosaveRun describes the collab-snapshot versions one timeline entry
// stands for. Count includes the entry's own row, so it is at least 2.
// OldestVersionID is the run's first row: its "before" is the body the run
// started from, which is what a diff of the whole run pairs with the newest
// row's "after". The line counts are the SUM of the rows' own counts and are
// present only when every row in the run carries one.
type AutosaveRun struct {
Count int `json:"count"`
FirstAt time.Time `json:"first_at"`
OldestVersionID string `json:"oldest_version_id"`
LinesAdded *int `json:"lines_added,omitempty"`
LinesRemoved *int `json:"lines_removed,omitempty"`
}
// TimelineResponse is the paginated response from the timeline endpoint.
//
// NextBefore / NextBeforeID are the cursor for the following page, set
// whenever HasMore is true, and a client must page with THEM rather than with
// the last entry it received (BUG-2765). The two differ exactly when the
// server discarded rows: the handler over-fetches per source and drops what
// 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 at all,
// while more history waits behind them. A cursor derived from the last
// rendered entry then either cannot be formed or does not advance, and the
// client re-requests the same window forever.
//
// The value is the position the next page must start strictly before, in the
// same (created_at, id) space the store's cursor queries use.
type TimelineResponse struct {
Entries []TimelineEntry `json:"entries"`
HasMore bool `json:"has_more"`
NextBefore string `json:"next_before,omitempty"`
NextBeforeID string `json:"next_before_id,omitempty"`
}
// AgentNameFromMetadata returns the agent display name stamped on an
// activity's metadata JSON (`handlers_documents.go::agentMeta` writes the
// `agent` key from the X-Pad-Agent header), or "" when the metadata is empty,
// unparseable, or carries no non-empty string under that key. It is the Go
// twin of the web client's agentNameOf (web/src/lib/utils/agentActor.ts) and
// applies the same contract: verbatim, no normalization, and a non-string
// value counts as absent rather than being rendered.
//
// Parsing happens here, in Go, rather than in SQL: the store targets both
// SQLite and Postgres, whose JSON accessors (json_extract vs ->>) differ, and
// the comment queries currently have no dialect fork to add one to.
func AgentNameFromMetadata(metadata string) string {
if metadata == "" {
return ""
}
var m map[string]any
if err := json.Unmarshal([]byte(metadata), &m); err != nil {
return ""
}
name, _ := m["agent"].(string)
return name
}
// PatchIntervening is what a rebased patch reports about the writes that
// landed between the seq its caller read and the seq it applied over
// (PLAN-3578). Changes are walked newest first along the activity seq chain
// (metadata seq / prev_seq). Complete is true only when that chain is
// unbroken from ToSeq back to FromSeq: a write that recorded no stamped entry
// (a path that writes none, an entry that failed, a write from before the
// stamps existed) breaks it, and then Complete is false.
type PatchIntervening struct {
FromSeq int64 `json:"from_seq"`
ToSeq int64 `json:"to_seq"`
Changes []PatchInterveningChange `json:"changes"`
Complete bool `json:"complete"`
Truncated bool `json:"truncated,omitempty"`
}
// PatchInterveningChange is one stamped write in that range.
type PatchInterveningChange struct {
ActivityID string `json:"activity_id"`
Seq int64 `json:"seq"`
PrevSeq int64 `json:"prev_seq"`
Action string `json:"action"`
Actor string `json:"actor"`
Source string `json:"source"`
At time.Time `json:"at"`
VersionID string `json:"version_id,omitempty"`
}