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
+31
View File
@@ -250,3 +250,34 @@ to the same reply. The focused V-717 race cases pass in 4.529s; every clarify
case plus all 22 forced dialogue traces pass under the race detector in
26.202s; `internal/dialogue` passes under race in 2.293s. Routing contract:
`docs/routing.md` section “Required slots and attempt exhaustion”.
### A suppressed nudge is identified before it is phrased
V-687 closes the phrase-before-dedupe hole in the digestion worker. The dedupe
was reported by `EnqueueDigestEntry`, which runs after `PhraseNudge` has already
been paid, and the `else if deduped { continue }` 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, against the cache claim in the comment above it.
The fix gives a rule a durable semantic identity instead of hashing its prose. A
rule eligible for the digest declares `DigestIdentity`, a function of state
beside its predicate; `loop.DigestCandidateFingerprint` frames the rule name and
severity around it so two rules cannot alias on a shared fact. `BreakRule`
anchors on the last completed break rather than on `desk_active`, which is
freshness evidence the poller refreshes without the unmet need changing. A rule
with no declared identity does not enter the digest, because inventing a generic
state hash would either change every tick or ignore an input the rule reads.
`tick_digest.go` now looks up `LiveDigestEntry` by rule and fingerprint 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 cover the contract: one phrase call across three suppressed ticks,
zero after a daemon restart, and two when the meaning changes, when the entry
expires, and when it has been drained. `./cmd/mavend/ -run TestSuppressedCareDigest`
passes under race in 4.626s, the digest store and loop cases in 4.123s and
1.046s, and the three full packages in 264.076s, 64.496s and 4.280s. The caveat
`docs/caveats/workers.md#nudges` and the `SA4006` baseline entry are deleted.