store: Sighting counters and the owner's clear control (#102)

The owner's page (issue #102) shows each Reader's Sighting record and offers
one action to wipe it. The counters ship before the Sighting feature that
moves them (issue #103) on purpose: the remedy for a false mark must exist
before marks can be made, or the first broken adapter is fixed with SQL
against production.

- 0010 adds sighting_agreements / sighting_disagreements, both zero-defaulted,
  so every existing Reader reads as trusted.
- ReaderSummary carries both, plus Blocked() against SightingDisagreementLimit,
  so the page states the verdict rather than making the owner derive it.
- ClearReaderMarks zeroes one Reader's pair.
This commit is contained in:
2026-08-16 14:17:10 +07:00
parent 3303a55b20
commit cdd4eb7c25
3 changed files with 121 additions and 6 deletions
@@ -0,0 +1,6 @@
-- The owner's administrative page (issue #102) renders these counters and
-- offers a control to clear them, deliberately shipped before the Sighting
-- feature (issue #103) that fills them, so a false mark never needs SQL
-- against production. Zero counters mean a trusted Reader.
ALTER TABLE readers ADD COLUMN sighting_agreements integer NOT NULL DEFAULT 0;
ALTER TABLE readers ADD COLUMN sighting_disagreements integer NOT NULL DEFAULT 0;
+35 -6
View File
@@ -315,25 +315,42 @@ func (s *Store) EnsureReader(discordID string, epochZeroHash [32]byte) (int64, e
return id, nil return id, nil
} }
// SightingDisagreementLimit is the number of contradictions that stop that
// Reader's Sightings from deferring a Poll (issue #103). The counters exist
// before the mechanism that moves them, so the admin page (issue #102) can
// clear a false mark without waiting for the Sighting feature.
const SightingDisagreementLimit = 3
// Blocked reports whether this Reader's Sighting marks have reached the
// disagreement limit, which stops their Sightings from deferring a Poll.
func (r ReaderSummary) Blocked() bool {
return r.Disagreements >= SightingDisagreementLimit
}
// ReaderSummary is one Reader as the owner's administration panel sees them: // ReaderSummary is one Reader as the owner's administration panel sees them:
// who they are and how many live sessions they hold. No credential material, // who they are, how many live sessions they hold, and their Sighting marks.
// hashed or otherwise, is exposed. // No credential material, hashed or otherwise, is exposed.
type ReaderSummary struct { type ReaderSummary struct {
ID int64 ID int64
DiscordID string DiscordID string
// Sessions counts unexpired session rows — what the owner revokes. // Sessions counts unexpired session rows — what the owner revokes.
Sessions int Sessions int
// Agreements and Disagreements are the Sighting counters (issue #102);
// zero means a trusted Reader.
Agreements int
Disagreements int
} }
// Readers lists every Reader with their live session count, oldest first, so // Readers lists every Reader with their live session count and Sighting
// the owner row (always the oldest) heads the list. // marks, oldest first, so the owner row (always the oldest) heads the list.
func (s *Store) Readers() ([]ReaderSummary, error) { func (s *Store) Readers() ([]ReaderSummary, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT r.id, r.discord_id, SELECT r.id, r.discord_id,
r.sighting_agreements, r.sighting_disagreements,
count(sess.id) FILTER (WHERE sess.expires_at > now()) AS sessions count(sess.id) FILTER (WHERE sess.expires_at > now()) AS sessions
FROM readers r FROM readers r
LEFT JOIN sessions sess ON sess.reader_id = r.id LEFT JOIN sessions sess ON sess.reader_id = r.id
GROUP BY r.id, r.discord_id GROUP BY r.id, r.discord_id, r.sighting_agreements, r.sighting_disagreements
ORDER BY r.id`) ORDER BY r.id`)
if err != nil { if err != nil {
return nil, fmt.Errorf("query readers: %w", err) return nil, fmt.Errorf("query readers: %w", err)
@@ -343,7 +360,7 @@ func (s *Store) Readers() ([]ReaderSummary, error) {
out := []ReaderSummary{} out := []ReaderSummary{}
for rows.Next() { for rows.Next() {
var r ReaderSummary var r ReaderSummary
if err := rows.Scan(&r.ID, &r.DiscordID, &r.Sessions); err != nil { if err := rows.Scan(&r.ID, &r.DiscordID, &r.Agreements, &r.Disagreements, &r.Sessions); err != nil {
return nil, fmt.Errorf("scan reader: %w", err) return nil, fmt.Errorf("scan reader: %w", err)
} }
out = append(out, r) out = append(out, r)
@@ -351,6 +368,18 @@ func (s *Store) Readers() ([]ReaderSummary, error) {
return out, rows.Err() return out, rows.Err()
} }
// ClearReaderMarks zeroes a Reader's Sighting counters. It is the owner's
// remedy for a mark produced by a broken Site adapter rather than a dishonest
// Reader: it restores a privilege, it is not destruction.
func (s *Store) ClearReaderMarks(readerID int64) error {
if _, err := s.db.Exec(`
UPDATE readers SET sighting_agreements = 0, sighting_disagreements = 0
WHERE id = $1`, readerID); err != nil {
return fmt.Errorf("clear reader marks for reader %d: %w", readerID, err)
}
return nil
}
// readersMigration is the version that creates the readers table. The owner // readersMigration is the version that creates the readers table. The owner
// seed runs between two migrate passes, so that the run-once migration which // seed runs between two migrate passes, so that the run-once migration which
// attaches existing bookmarks (0004) finds the owner row. // attaches existing bookmarks (0004) finds the owner row.
+80
View File
@@ -1334,6 +1334,86 @@ func TestReadersAndSessionRevocation(t *testing.T) {
} }
} }
// The Sighting counters ship before the mechanism that fills them (issue
// #102 before #103), so the admin page depends on their default: a fresh
// Reader reads back trusted. Marking one directly proves Readers() reports
// the counters and Blocked() flips at the limit, and that ClearReaderMarks —
// the owner's remedy for a false mark — zeroes them again.
func TestReaderSightingMarks(t *testing.T) {
s := newTestStore(t)
other := secondReader(t, s)
readers, err := s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
if len(readers) != 2 {
t.Fatalf("readers = %d, want the owner and the second Reader", len(readers))
}
for _, r := range readers {
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
t.Fatalf("fresh reader %d has marks: %+v", r.ID, r)
}
}
// The counters' default is the trusted state; writing them directly is
// the only way to exercise the read path until issue #103 moves them.
if _, err := s.db.Exec(`
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
WHERE id = $1`, other); err != nil {
t.Fatalf("mark reader: %v", err)
}
readers, err = s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
var marked *ReaderSummary
for i := range readers {
if readers[i].ID == other {
marked = &readers[i]
}
}
if marked == nil || marked.Agreements != 5 || marked.Disagreements != 2 {
t.Fatalf("marked reader = %+v, want agreements 5, disagreements 2", marked)
}
if marked.Blocked() {
t.Fatalf("reader with 2 disagreements is blocked; limit is %d", SightingDisagreementLimit)
}
if _, err := s.db.Exec(`
UPDATE readers SET sighting_disagreements = 3 WHERE id = $1`, other); err != nil {
t.Fatalf("block reader: %v", err)
}
readers, err = s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
for _, r := range readers {
if r.ID == other && !r.Blocked() {
t.Fatalf("reader at the disagreement limit is not blocked: %+v", r)
}
if r.ID == s.OwnerID() && r.Blocked() {
t.Fatalf("untouched owner became blocked: %+v", r)
}
}
if err := s.ClearReaderMarks(other); err != nil {
t.Fatalf("ClearReaderMarks: %v", err)
}
if err := s.ClearReaderMarks(other + 9999); err != nil {
t.Fatalf("ClearReaderMarks(unknown id): %v", err)
}
readers, err = s.Readers()
if err != nil {
t.Fatalf("Readers: %v", err)
}
for _, r := range readers {
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
t.Fatalf("reader %d not cleared: %+v", r.ID, r)
}
}
}
// Two Readers on one Series: one series row, two independent progresses. The // Two Readers on one Series: one series row, two independent progresses. The
// second Reader starts at zero however far the first has read, and the shared // second Reader starts at zero however far the first has read, and the shared
// row is still due exactly once. // row is still due exactly once.