Files
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

223 lines
6.7 KiB
Go

// Package verification judges stored verification rows against an item's
// present acceptance criteria (TASK-3617). It is pure: the store persists
// rows, the server reads the item and its linked PRs, and this package says
// what each row means now.
//
// Freshness keys on the CRITERION, not the item's seq: a row goes stale when
// the text of its own AC-N changes or disappears, and an edit anywhere else in
// the item leaves it current. Decision freshness made the same choice for the
// same reason (internal/decision, a per-comment row is judged against its own
// comment).
package verification
import (
"crypto/sha256"
"encoding/hex"
"sort"
"strconv"
"strings"
"time"
"github.com/PerpetualSoftware/pad/internal/acceptance"
"github.com/PerpetualSoftware/pad/internal/models"
)
// CriterionHash is the sha256 hex of a criterion's text with runs of
// whitespace collapsed, so re-wrapping a criterion does not stale its
// evidence and rewording it does.
func CriterionHash(text string) string {
sum := sha256.Sum256([]byte(strings.Join(strings.Fields(text), " ")))
return hex.EncodeToString(sum[:])
}
// Lookup is what a write needs to know about one criterion id.
type Lookup int
const (
// LookupFound: exactly one real criterion carries the id.
LookupFound Lookup = iota
// LookupMissing: no real criterion carries it. A placeholder (an id still
// holding skeleton text) is missing too: there is nothing to verify yet.
LookupMissing
// LookupAmbiguous: the id appears more than once, so a verdict could not
// say which text it was about.
LookupAmbiguous
)
// Criteria indexes a parsed section by id.
type Criteria struct {
text map[string]string
count map[string]int
placeholder map[string]bool
ids []string
}
// Index builds the lookup for sec. A body with no acceptance-criteria section
// yields an empty index, in which every id is missing.
func Index(sec acceptance.Section) Criteria {
c := Criteria{text: map[string]string{}, count: map[string]int{}, placeholder: map[string]bool{}}
for _, cr := range sec.Criteria {
if c.count[cr.ID] == 0 {
c.text[cr.ID] = cr.Text
c.ids = append(c.ids, cr.ID)
}
c.count[cr.ID]++
}
for _, id := range sec.Placeholders {
c.placeholder[id] = true
}
return c
}
// Find reports how id resolves and, when it is found, its text's hash.
func (c Criteria) Find(id string) (Lookup, string) {
n := c.count[id]
switch {
case n > 1 || (n == 1 && c.placeholder[id]):
return LookupAmbiguous, ""
case n == 1:
return LookupFound, CriterionHash(c.text[id])
default:
return LookupMissing, ""
}
}
// IDs lists the real (non-placeholder) criterion ids, in body order, once each.
func (c Criteria) IDs() []string {
out := make([]string, 0, len(c.ids))
for _, id := range c.ids {
if l, _ := c.Find(id); l == LookupFound {
out = append(out, id)
}
}
return out
}
// LinkedPR is a merged PR whose merge may have moved the code a verdict was
// about: the item's own github_pr, or the github_pr of the item named as via.
type LinkedPR struct {
Number int
MergeSHA string
MergedAt string
}
// Judge sets State on every row, and PossiblyStale where a linked PR merged
// after the row was recorded. rows may be any subset of one item's history;
// "superseded" is decided within rows, so pass the whole history (or at least
// every row for a criterion) when it matters.
//
// The order is the design's: removed, then changed, then superseded, then
// current. A row whose criterion was reworded reads criterion_changed even
// when a newer row exists, because that is the more useful thing to know about
// it.
func Judge(rows []models.ItemVerification, c Criteria, prs []LinkedPR) {
newest := map[string]int{}
for i, r := range rows {
j, ok := newest[r.CriterionID]
if !ok || newer(r, rows[j]) {
newest[r.CriterionID] = i
}
}
for i := range rows {
r := &rows[i]
lookup, hash := c.Find(r.CriterionID)
switch {
case lookup == LookupMissing:
r.State = models.VerificationCriterionRemoved
case lookup == LookupAmbiguous || hash != r.CriterionHash:
r.State = models.VerificationCriterionChanged
case newest[r.CriterionID] != i:
r.State = models.VerificationSuperseded
default:
r.State = models.VerificationCurrent
}
r.PossiblyStale = staleHint(*r, prs)
}
}
// Latest keeps the newest row per criterion, in the order first seen.
func Latest(rows []models.ItemVerification) []models.ItemVerification {
idx := map[string]int{}
var out []models.ItemVerification
for _, r := range rows {
j, ok := idx[r.CriterionID]
if !ok {
idx[r.CriterionID] = len(out)
out = append(out, r)
continue
}
if newer(r, out[j]) {
out[j] = r
}
}
return out
}
// SortByCriteria orders rows the way a reader reads the spec: by the
// criterion's position in the body, then oldest first within a criterion.
// Rows whose criterion is no longer in the body come last, by id, then time.
// One batch shares a created_at, so without this its rows would come back in
// id order, which is random.
func SortByCriteria(rows []models.ItemVerification, c Criteria) {
pos := map[string]int{}
for i, id := range c.ids {
pos[id] = i
}
sort.SliceStable(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
pa, oka := pos[a.CriterionID]
pb, okb := pos[b.CriterionID]
switch {
case oka != okb:
return oka
case oka && pa != pb:
return pa < pb
case !oka && a.CriterionID != b.CriterionID:
return a.CriterionID < b.CriterionID
}
return newer(b, a)
})
}
// newer orders rows by created_at, then id, the same tiebreak the store's
// latest-row read uses. created_at is fixed-width, so string order is time
// order.
func newer(a, b models.ItemVerification) bool {
if a.CreatedAt != b.CreatedAt {
return a.CreatedAt > b.CreatedAt
}
return a.ID > b.ID
}
// staleHint returns the first linked PR that merged after the row was
// recorded at a different revision. A row with no code revision is not about
// code, and a PR whose merge commit IS the row's revision did not move it.
//
// The server cannot see the repository, so "after" is by time: the merge time
// against the row's created_at. That is why the hint is advisory and names
// its evidence rather than changing State.
func staleHint(r models.ItemVerification, prs []LinkedPR) *models.VerificationStaleHint {
if r.CodeRevision == "" {
return nil
}
recorded, err := time.Parse(time.RFC3339Nano, r.CreatedAt)
if err != nil {
return nil
}
for _, pr := range prs {
if pr.MergeSHA == "" || pr.MergeSHA == r.CodeRevision {
continue
}
merged, err := time.Parse(time.RFC3339, pr.MergedAt)
if err != nil || !merged.After(recorded) {
continue
}
return &models.VerificationStaleHint{
Via: "PR #" + strconv.Itoa(pr.Number),
MergedSHA: pr.MergeSHA,
MergedAt: pr.MergedAt,
}
}
return nil
}