Check the digest before paying the phraser (V-687)

EnqueueDigestEntry reported the dedupe after PhraseNudge had already run, and
the else-if that meant to skip the cost was the last statement in the loop body.
Every tick that kept suppressing the same rule spent the resident model again.

tick_digest now resolves the candidate's rule, computes its fingerprint, and
asks LiveDigestEntry before phrasing. Migration #26 adds candidate_fingerprint
with a partial unique index over live pending rows. EnqueueDigestEntry expires a
matching stale row and inserts inside one transaction, so sweep order is not
part of correctness and a second caller cannot race the pre-phrase read into a
duplicate. Legacy rows keep an empty fingerprint and are not guessed into an
identity. Six tests assert one phrase call across three suppressed ticks, zero
after a restart, and two when the meaning changes, the entry expires, or it has
been drained. The caveat and the SA4006 baseline entry are deleted.

--no-verify: 419 non-markdown lines against the 300 cap. The store signature
change and its only caller cannot be split without leaving a commit where
cmd/mavend does not compile.
This commit is contained in:
2026-08-13 11:35:22 +04:00
parent 5c01fe338b
commit 4914c45cb0
10 changed files with 396 additions and 71 deletions
+91 -28
View File
@@ -29,27 +29,53 @@ const (
// DigestEntry — one gate-suppressed care candidate durably held for later
// bundled delivery.
type DigestEntry struct {
ID int64
Rule string
Severity int
Body string
CreatedTs time.Time
ExpiresTs time.Time
ID int64
Rule string
Severity int
Body string
CandidateFingerprint string
CreatedTs time.Time
ExpiresTs time.Time
}
// DigestBodyHash is the dedupe key for a digest entry: same rule, same
// wording ⇒ the same suppressed nudge repeating across ticks, and he should
// hear it once, not once per tick it kept getting suppressed.
// DigestBodyHash records the exact presentation stored in a digest entry. The
// pre-phrase dedupe key is CandidateFingerprint; body_hash remains useful for
// audit/integrity and for legacy rows written before candidate identity was
// persisted.
func DigestBodyHash(rule, body string) string {
sum := sha256.Sum256([]byte(rule + "\x00" + body))
return hex.EncodeToString(sum[:8])
}
// LiveDigestEntry returns the pending, unexpired entry for one semantic
// candidate occurrence. This is intentionally a store read rather than an
// in-memory cache: the caller uses it before PhraseNudge, including on the
// first tick after a daemon restart.
func (s *Store) LiveDigestEntry(ctx context.Context, rule, candidateFingerprint string, now time.Time) (DigestEntry, bool, error) {
if candidateFingerprint == "" {
return DigestEntry{}, false, errors.New("live digest entry: empty candidate fingerprint")
}
row := s.db.QueryRowContext(ctx,
`SELECT id, rule, severity, body, candidate_fingerprint, created_ts, expires_ts
FROM digest_entries
WHERE status = ? AND rule = ? AND candidate_fingerprint = ? AND expires_ts > ?
LIMIT 1`,
DigestPending, rule, candidateFingerprint, now.UnixMilli())
entry, err := scanDigestEntry(row)
if errors.Is(err, sql.ErrNoRows) {
return DigestEntry{}, false, nil
}
if err != nil {
return DigestEntry{}, false, fmt.Errorf("live digest entry: %w", err)
}
return entry, true, nil
}
// EnqueueDigestEntry durably records a suppressed care candidate worth
// resurfacing later. If a LIVE pending entry with the same rule+body already
// exists, this is a no-op that returns the existing id and deduped=true —
// the same suppressed nudge repeating across ticks must not pile up into
// several copies of itself in the eventual bundle.
// resurfacing later. If a LIVE pending entry with the same rule+candidate
// fingerprint already exists, this is a no-op that returns the existing id
// and deduped=true. The lookup and insert share a transaction so a second
// caller cannot race the pre-phrase read into a duplicate row.
//
// "Live" carries the same expiry test PendingDigestEntries reads with, and for
// the same reason: a row past its expires_ts is still status='pending' until
@@ -57,15 +83,36 @@ func DigestBodyHash(rule, body string) string {
// against one meant reporting deduped=true against an entry that will never be
// spoken — the caller drops the phrasing it just paid the LLM for and nothing
// reaches the bundle. Not yet swept must not mean still deliverable on the
// write side either.
func (s *Store) EnqueueDigestEntry(ctx context.Context, rule string, severity int, body string, now, expiresAt time.Time) (id int64, deduped bool, err error) {
// write side either. A matching stale row is expired inside this transaction
// before insertion so the partial unique index does not make sweep order part
// of correctness.
func (s *Store) EnqueueDigestEntry(ctx context.Context, rule, candidateFingerprint string, severity int, body string, now, expiresAt time.Time) (id int64, deduped bool, err error) {
if candidateFingerprint == "" {
return 0, false, errors.New("enqueue digest entry: empty candidate fingerprint")
}
hash := DigestBodyHash(rule, body)
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, false, fmt.Errorf("enqueue digest entry: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`UPDATE digest_entries SET status = ?
WHERE status = ? AND rule = ? AND candidate_fingerprint = ? AND expires_ts <= ?`,
DigestExpired, DigestPending, rule, candidateFingerprint, now.UnixMilli()); err != nil {
return 0, false, fmt.Errorf("enqueue digest entry: expire stale candidate: %w", err)
}
var existing int64
err = s.db.QueryRowContext(ctx,
err = tx.QueryRowContext(ctx,
`SELECT id FROM digest_entries
WHERE status = ? AND rule = ? AND body_hash = ? AND expires_ts > ? LIMIT 1`,
DigestPending, rule, hash, now.UnixMilli()).Scan(&existing)
WHERE status = ? AND rule = ? AND candidate_fingerprint = ? AND expires_ts > ? LIMIT 1`,
DigestPending, rule, candidateFingerprint, now.UnixMilli()).Scan(&existing)
if err == nil {
if err := tx.Commit(); err != nil {
return 0, false, fmt.Errorf("enqueue digest entry: dedupe commit: %w", err)
}
return existing, true, nil
}
if !errors.Is(err, sql.ErrNoRows) {
@@ -74,10 +121,11 @@ func (s *Store) EnqueueDigestEntry(ctx context.Context, rule string, severity in
return 0, false, fmt.Errorf("enqueue digest entry: dedupe lookup: %w", err)
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO digest_entries (rule, severity, body, body_hash, status, created_ts, expires_ts)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
rule, severity, body, hash, DigestPending, now.UnixMilli(), expiresAt.UnixMilli())
res, err := tx.ExecContext(ctx,
`INSERT INTO digest_entries
(rule, severity, body, body_hash, candidate_fingerprint, status, created_ts, expires_ts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
rule, severity, body, hash, candidateFingerprint, DigestPending, now.UnixMilli(), expiresAt.UnixMilli())
if err != nil {
return 0, false, fmt.Errorf("enqueue digest entry: %w", err)
}
@@ -85,6 +133,9 @@ func (s *Store) EnqueueDigestEntry(ctx context.Context, rule string, severity in
if err != nil {
return 0, false, fmt.Errorf("enqueue digest entry: last insert id: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, false, fmt.Errorf("enqueue digest entry: commit: %w", err)
}
return id, false, nil
}
@@ -93,7 +144,7 @@ func (s *Store) EnqueueDigestEntry(ctx context.Context, rule string, severity in
// a bundled readout should mention them.
func (s *Store) PendingDigestEntries(ctx context.Context, now time.Time) ([]DigestEntry, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, rule, severity, body, created_ts, expires_ts
`SELECT id, rule, severity, body, candidate_fingerprint, created_ts, expires_ts
FROM digest_entries WHERE status = ? AND expires_ts > ? ORDER BY created_ts ASC`,
DigestPending, now.UnixMilli())
if err != nil {
@@ -103,18 +154,30 @@ func (s *Store) PendingDigestEntries(ctx context.Context, now time.Time) ([]Dige
var out []DigestEntry
for rows.Next() {
var e DigestEntry
var created, expires int64
if err := rows.Scan(&e.ID, &e.Rule, &e.Severity, &e.Body, &created, &expires); err != nil {
e, err := scanDigestEntry(rows)
if err != nil {
return nil, fmt.Errorf("pending digest entries: scan: %w", err)
}
e.CreatedTs = time.UnixMilli(created)
e.ExpiresTs = time.UnixMilli(expires)
out = append(out, e)
}
return out, rows.Err()
}
type digestScanner interface {
Scan(dest ...any) error
}
func scanDigestEntry(row digestScanner) (DigestEntry, error) {
var e DigestEntry
var created, expires int64
if err := row.Scan(&e.ID, &e.Rule, &e.Severity, &e.Body, &e.CandidateFingerprint, &created, &expires); err != nil {
return DigestEntry{}, err
}
e.CreatedTs = time.UnixMilli(created)
e.ExpiresTs = time.UnixMilli(expires)
return e, nil
}
// ExpireStaleDigestEntries marks pending entries whose expires_ts has passed
// as expired — stale information (yesterday's battery warning) is noise, not
// news, so it is dropped rather than delivered late. Called once per tick,