review: clear stale attribution, gate numberless PUTs, cite the ADR

A Poll finding a higher number now clears series.latest_raised_by: the value
it stores is its own, so the next Poll must not credit that Reader with an
agreement they did not earn, nor charge them for a retraction of a number
they never reported.

A PUT carrying no chapter is no Sighting and defers nothing. The ceiling now
counts the Site's own rest rather than defaultRest. The userscripts PUT an
unchanged read too - the case the whole mechanism exists for was the one they
never sent.

ADR-0011 carries its citations and states honestly when a Reader's marks take
effect.
This commit is contained in:
2026-08-16 16:36:31 +07:00
parent 56afb9f237
commit 2b597921f4
13 changed files with 2959 additions and 1003 deletions
+31 -15
View File
@@ -589,9 +589,8 @@ func (s *Store) scanBookmark(scan func(...any) error) (Bookmark, error) {
}
// scanSeries reads one row in seriesColumns order, plus the due query's
// reader_count column. latest_chapter_num is NULL until the first capture,
// same as on the bookmark read path, and latest_raised_by is NULL whenever no
// Sighting is currently answerable for the stored value.
// reader_count column. latest_chapter_num and latest_raised_by are both
// nullable, same as latest_chapter_num on the bookmark read path.
func scanSeries(scan func(...any) error) (Series, error) {
var (
sr Series
@@ -1087,11 +1086,13 @@ func (s *Store) SetLatestChapter(site, seriesID, label string, num float64) erro
// including for a marked Reader — their Sightings are still judged, which is
// how they earn the privilege back.
//
// num is the reported chapter number, nil when the client sent none.
// num is the reported chapter number. A PUT that carries none — a favourite
// toggle, or progress written from a chapter page — is no Sighting at all:
// nobody read the Series page, so there is nothing to stand in for a Poll and
// nothing that could later be judged.
func (s *Store) RecordSighting(readerID int64, site, seriesID string, num *float64, ts int64) error {
var reported any
if num != nil {
reported = *num
if num == nil {
return nil
}
if _, err := s.db.Exec(`
UPDATE series SET
@@ -1099,11 +1100,10 @@ func (s *Store) RecordSighting(readerID int64, site, seriesID string, num *float
WHEN (SELECT sighting_disagreements FROM readers WHERE id = $3) < $6
THEN $4::bigint ELSE latest_sighted_at END,
latest_raised_by = CASE
WHEN $5::double precision IS NOT NULL
AND (latest_chapter_num IS NULL OR $5::double precision > latest_chapter_num)
WHEN latest_chapter_num IS NULL OR $5::double precision > latest_chapter_num
THEN $3::bigint ELSE latest_raised_by END
WHERE site = $1 AND series_id = $2`,
site, seriesID, readerID, ts, reported, SightingDisagreementLimit); err != nil {
site, seriesID, readerID, ts, *num, SightingDisagreementLimit); err != nil {
return fmt.Errorf("record sighting %s:%s: %w", site, seriesID, err)
}
return nil
@@ -1123,8 +1123,8 @@ const SightingAgreementsToClear = 20
// the Poll confirming the stored value; its opposite is the Poll finding a
// lower number, which means the raise was false.
//
// A Poll finding a *higher* number is neither — the Site published — and must
// not reach this at all.
// A Poll finding a *higher* number is neither — the Site published — and takes
// ClearSightingAttribution instead.
func (s *Store) RecordSightingOutcome(site, seriesID string, readerID int64, agreed bool) error {
tx, err := s.db.Begin()
if err != nil {
@@ -1151,9 +1151,7 @@ func (s *Store) RecordSightingOutcome(site, seriesID string, readerID int64, agr
if _, err := tx.Exec(q, args...); err != nil {
return fmt.Errorf("record sighting outcome for reader %d: %w", readerID, err)
}
if _, err := tx.Exec(
`UPDATE series SET latest_raised_by = NULL WHERE site = $1 AND series_id = $2`,
site, seriesID); err != nil {
if _, err := tx.Exec(clearAttributionSQL, site, seriesID, readerID); err != nil {
return fmt.Errorf("clear sighting attribution %s:%s: %w", site, seriesID, err)
}
if err := tx.Commit(); err != nil {
@@ -1161,3 +1159,21 @@ func (s *Store) RecordSightingOutcome(site, seriesID string, readerID int64, agr
}
return nil
}
// ClearSightingAttribution answers a Sighting without judging it: the Poll
// found a higher number, so the value about to be stored is its own and this
// Reader is no longer answerable for the row. Without it the next Poll's
// agreement would be credited to a Reader who did not earn it.
func (s *Store) ClearSightingAttribution(site, seriesID string, readerID int64) error {
if _, err := s.db.Exec(clearAttributionSQL, site, seriesID, readerID); err != nil {
return fmt.Errorf("clear sighting attribution %s:%s: %w", site, seriesID, err)
}
return nil
}
// clearAttributionSQL drops the attribution only while it still names the
// Reader being judged: a Sighting landing between the due query's snapshot and
// this write is a fresh, unjudged one and must not be erased by the previous
// one's verdict.
const clearAttributionSQL = `UPDATE series SET latest_raised_by = NULL
WHERE site = $1 AND series_id = $2 AND latest_raised_by = $3`