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
+20 -12
View File
@@ -79,7 +79,9 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
each re-checking that Site's bookmarked series' newest published chapter from each re-checking that Site's bookmarked series' newest published chapter from
backend's own network access, so `latest_chapter` stays fresh when the user backend's own network access, so `latest_chapter` stays fresh when the user
isn't browsing. Second, parallel signal — the userscript keeps its own isn't browsing. Second, parallel signal — the userscript keeps its own
`maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` logic unchanged. `maybeCaptureLatestOnSeriesPage`/`backgroundRefreshLatest` schedule, and its
`reportLatestChapter` PUTs every read, unchanged numbers included, because an
unchanged read is exactly the Sighting worth deferring a Poll on (#103).
Two independent clocks: per-series rest (`series.latest_checked_at`, Two independent clocks: per-series rest (`series.latest_checked_at`,
enforced by `Store.DueForLatestCheck`'s WHERE clause — `now - Rest`) and enforced by `Store.DueForLatestCheck`'s WHERE clause — `now - Rest`) and
per-Lane gap (the Lane sleeping between fetches, `effectiveGap`). Both live per-Lane gap (the Lane sleeping between fetches, `effectiveGap`). Both live
@@ -98,17 +100,23 @@ Guidance for OpenCode (and Claude Code) working under `backend/`. See root `AGEN
`series.latest_sighted_at` and, when the report raises the stored number, `series.latest_sighted_at` and, when the report raises the stored number,
names its Reader in `series.latest_raised_by`. The due query's HAVING clause names its Reader in `series.latest_raised_by`. The due query's HAVING clause
is where deferral lives: a Series is skipped only while it has exactly one is where deferral lives: a Series is skipped only while it has exactly one
Bookmark, was sighted within one Rest, and is under the `sightingCeiling` Bookmark, was sighted within one Rest, and is under the ceiling
(six rests) since its last Poll. So a shared Series is never deferred, and no (`sightingCeilingRests`, six of that Site's rests) since its last Poll. So a
Series goes six hours unpolled whatever arrives. `checkOne` judges the named shared Series is never deferred, and no Series goes six hours unpolled
Reader off the comparison it already makes: a lower number is a whatever arrives. `checkOne` judges the named Reader off the comparison it
contradiction (logged with the Reader and both numbers), the same number an already makes: a lower number is a contradiction (logged with the Reader and
agreement, a higher number the Site publishing and neither. Three both numbers), the same number an agreement, a higher number the Site
contradictions (`store.SightingDisagreementLimit`) stop that Reader deferring publishing and neither — that last one clears the attribution instead, since
— their reports still write the Latest Chapter — and twenty consecutive the value the Poll then stores is its own and a later retraction is not the
agreements (`store.SightingAgreementsToClear`) forgive them, as does the Reader's fault. Three contradictions
owner's clear-marks control. Deferral is recomputed from live facts every (`store.SightingDisagreementLimit`) stop that Reader deferring — their
round, so nothing needs invalidating when a Series gains a second Bookmark. reports still write the Latest Chapter — and twenty consecutive agreements
(`store.SightingAgreementsToClear`) forgive them, as does the owner's
clear-marks control. Deferral is recomputed from live facts every round, so
nothing needs invalidating when a Series gains a second Bookmark; the one
input read earlier is the Reader's marks, checked when the Sighting is
recorded, so crossing the threshold or being cleared takes effect from that
Reader's next Sighting and the standing already bought lasts out its rest.
Refusals and browser loss are Lane-local: two `errChallengeHeld` in one pass Refusals and browser loss are Lane-local: two `errChallengeHeld` in one pass
stop that Site for `refuseBackoff` (15m) while other Lanes continue; an stop that Site for `refuseBackoff` (15m) while other Lanes continue; an
`errBrowserInterrupted` (remote Chrome restart) sets a shared Poller flag `errBrowserInterrupted` (remote Chrome restart) sets a shared Poller flag
+9 -2
View File
@@ -250,8 +250,8 @@ func (p *Poller) runLanePass(ctx context.Context, name string, paced bool) time.
return defaultGap return defaultGap
} }
due, err := p.Store.DueForLatestCheck(name, due, err := p.Store.DueForLatestCheck(name, now.Add(-s.Rest).UnixMilli(),
now.Add(-s.Rest).UnixMilli(), now.Add(-sightingCeiling).UnixMilli()) now.Add(-sightingCeilingRests*s.Rest).UnixMilli())
if err != nil { if err != nil {
log.Printf("latest poll %s: due query: %v", name, err) log.Printf("latest poll %s: due query: %v", name, err)
st.Gap = defaultGap st.Gap = defaultGap
@@ -503,6 +503,13 @@ func (p *Poller) judgeSighting(sr store.Series, found float64) {
} }
stored := *sr.LatestChapterNum stored := *sr.LatestChapterNum
if found > stored { if found > stored {
// The report is neither confirmed nor contradicted, but it is answered:
// the value about to be stored is the Poll's own, so leaving the
// attribution would credit this Reader with the next Poll's agreement
// and blame them if the Site later retracts.
if err := p.Store.ClearSightingAttribution(sr.Site, sr.SeriesID, *sr.LatestRaisedBy); err != nil {
log.Printf("latest poll %q: clear sighting attribution: %v", sr.Key(), err)
}
return return
} }
if found < stored { if found < stored {
+54 -1
View File
@@ -189,7 +189,7 @@ func TestSightingCeilingForcesPoll(t *testing.T) {
newTestPoller(t, s, f, now).runOnce(context.Background()) newTestPoller(t, s, f, now).runOnce(context.Background())
if got := f.callCount(); got != 1 { if got := f.callCount(); got != 1 {
t.Fatalf("fetched %d times past the %s ceiling, want 1", got, sightingCeiling) t.Fatalf("fetched %d times past the %s ceiling, want 1", got, sightingCeilingRests*defaultRest)
} }
} }
@@ -439,3 +439,56 @@ func TestPollFindingHigherNumberIsNotAContradiction(t *testing.T) {
t.Fatalf("a Reader whose report the Site overtook lost the right to defer (fetches %d, want 1)", got) t.Fatalf("a Reader whose report the Site overtook lost the right to defer (fetches %d, want 1)", got)
} }
} }
// A Poll that overtakes a Sighting takes ownership of the row: the value stored
// afterwards is the Poll's own, so a later retraction is not the Reader's fault
// and must not be charged to them.
func TestAttributionDoesNotSurviveAPollThatOvertookIt(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(20 * time.Hour.Milliseconds())
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
sight(t, s, s.OwnerID(), demonicKey, 295, now.Add(-10*time.Minute))
f := &fakeFetcher{body: demonicFixture(296), status: 200}
p := newTestPoller(t, s, f, now)
at := now.Add(defaultRest + time.Minute)
p.Now = func() time.Time { return at }
p.runOnce(context.Background())
if got := readLatestNum(t, s, s.OwnerID(), demonicKey); got != 296 {
t.Fatalf("latest after the Poll = %v, want the Site's own 296", got)
}
var logs strings.Builder
prev := log.Writer()
log.SetOutput(&logs)
t.Cleanup(func() { log.SetOutput(prev) })
f.body = demonicFixture(290) // the Site retracts what only the Poll wrote
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
p.runOnce(context.Background())
if strings.Contains(logs.String(), "sighting contradicted") {
t.Fatalf("a retraction of the Poll's own value was charged to a Reader: %s", logs.String())
}
}
// A PUT with no Latest Chapter in it — a favourite toggle, progress written
// from a chapter page — is nobody looking at the Series page, so it buys no
// deferral. Otherwise a client could suppress a Series' Polls while reporting
// nothing, and with nothing reported there would be nothing to judge.
func TestPutWithoutALatestChapterDoesNotDefer(t *testing.T) {
s, _ := newTestStore(t)
now := time.UnixMilli(20 * time.Hour.Milliseconds())
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
// The handler's own call, with the field the client omitted.
if err := s.RecordSighting(s.OwnerID(), "demonic", "Catastrophic-Necromancer",
nil, now.Add(-time.Minute).UnixMilli()); err != nil {
t.Fatalf("RecordSighting: %v", err)
}
f := &fakeFetcher{body: demonicFixture(296), status: 200}
p := newTestPoller(t, s, f, now)
p.runOnce(context.Background())
if got := f.callCount(); got != 1 {
t.Fatalf("fetched %d times after a PUT carrying no chapter, want 1", got)
}
}
+8 -7
View File
@@ -409,13 +409,14 @@ const (
// asleep (ADR-0005 on-demand browser). // asleep (ADR-0005 on-demand browser).
browserWakeCount = 5 browserWakeCount = 5
browserWakeAge = 15 * time.Minute browserWakeAge = 15 * time.Minute
// sightingCeiling caps Sighting deferral (issue #103): however many // sightingCeilingRests caps Sighting deferral (issue #103): however many
// Sightings arrive, a Series unpolled for this long is Polled. It is what // Sightings arrive, a Series unpolled for this many of its Site's rests is
// makes a client report safe to trust — a wrong Latest Chapter dies within // Polled. It is what makes a client report safe to trust — a wrong Latest
// the ceiling deterministically, rather than in expectation the way a // Chapter dies within the ceiling deterministically, rather than in
// randomised audit would have it. Six rests, so a Series a Reader visits // expectation the way a randomised audit would have it. Six, so a Series a
// constantly still gets one authoritative check per working day-part. // Reader visits constantly still gets one authoritative check per working
sightingCeiling = 6 * defaultRest // day-part.
sightingCeilingRests = 6
) )
// effectiveGap is a Site's pace: the registry gap, or one rest divided by the // effectiveGap is a Site's pace: the registry gap, or one rest divided by the
+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 // scanSeries reads one row in seriesColumns order, plus the due query's
// reader_count column. latest_chapter_num is NULL until the first capture, // reader_count column. latest_chapter_num and latest_raised_by are both
// same as on the bookmark read path, and latest_raised_by is NULL whenever no // nullable, same as latest_chapter_num on the bookmark read path.
// Sighting is currently answerable for the stored value.
func scanSeries(scan func(...any) error) (Series, error) { func scanSeries(scan func(...any) error) (Series, error) {
var ( var (
sr Series 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 // including for a marked Reader — their Sightings are still judged, which is
// how they earn the privilege back. // 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 { func (s *Store) RecordSighting(readerID int64, site, seriesID string, num *float64, ts int64) error {
var reported any if num == nil {
if num != nil { return nil
reported = *num
} }
if _, err := s.db.Exec(` if _, err := s.db.Exec(`
UPDATE series SET 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 WHEN (SELECT sighting_disagreements FROM readers WHERE id = $3) < $6
THEN $4::bigint ELSE latest_sighted_at END, THEN $4::bigint ELSE latest_sighted_at END,
latest_raised_by = CASE latest_raised_by = CASE
WHEN $5::double precision IS NOT NULL WHEN latest_chapter_num IS NULL OR $5::double precision > latest_chapter_num
AND (latest_chapter_num IS NULL OR $5::double precision > latest_chapter_num)
THEN $3::bigint ELSE latest_raised_by END THEN $3::bigint ELSE latest_raised_by END
WHERE site = $1 AND series_id = $2`, 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 fmt.Errorf("record sighting %s:%s: %w", site, seriesID, err)
} }
return nil return nil
@@ -1123,8 +1123,8 @@ const SightingAgreementsToClear = 20
// the Poll confirming the stored value; its opposite is the Poll finding a // the Poll confirming the stored value; its opposite is the Poll finding a
// lower number, which means the raise was false. // lower number, which means the raise was false.
// //
// A Poll finding a *higher* number is neither — the Site published — and must // A Poll finding a *higher* number is neither — the Site published — and takes
// not reach this at all. // ClearSightingAttribution instead.
func (s *Store) RecordSightingOutcome(site, seriesID string, readerID int64, agreed bool) error { func (s *Store) RecordSightingOutcome(site, seriesID string, readerID int64, agreed bool) error {
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { 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 { if _, err := tx.Exec(q, args...); err != nil {
return fmt.Errorf("record sighting outcome for reader %d: %w", readerID, err) return fmt.Errorf("record sighting outcome for reader %d: %w", readerID, err)
} }
if _, err := tx.Exec( if _, err := tx.Exec(clearAttributionSQL, site, seriesID, readerID); err != nil {
`UPDATE series SET latest_raised_by = NULL WHERE site = $1 AND series_id = $2`,
site, seriesID); err != nil {
return fmt.Errorf("clear sighting attribution %s:%s: %w", site, seriesID, err) return fmt.Errorf("clear sighting attribution %s:%s: %w", site, seriesID, err)
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
@@ -1161,3 +1159,21 @@ func (s *Store) RecordSightingOutcome(site, seriesID string, readerID int64, agr
} }
return nil 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`
+53 -20
View File
@@ -5,9 +5,9 @@ Status: accepted
## Decision ## Decision
A **Sighting** is what a userscript PUT already carries: the Latest Chapter the A **Sighting** is the Latest Chapter the Reader's own browser read off the
Reader's own browser read off the Series page. It is now allowed to stand in for Series page and PUT to the backend. It is now allowed to stand in for a Poll,
a Poll, under one restriction and one ceiling: under one restriction and one ceiling:
- **Solitary Series only.** A Sighting defers the Poll of a Series exactly one - **Solitary Series only.** A Sighting defers the Poll of a Series exactly one
Bookmark points at. A Series two Readers share is Polled on schedule no matter Bookmark points at. A Series two Readers share is Polled on schedule no matter
@@ -15,8 +15,9 @@ a Poll, under one restriction and one ceiling:
- **One rest of standing.** A Sighting postpones Polls for one Rest - **One rest of standing.** A Sighting postpones Polls for one Rest
(`defaultRest`, an hour), not forever: a Series nobody visits again returns to (`defaultRest`, an hour), not forever: a Series nobody visits again returns to
the normal schedule by itself. the normal schedule by itself.
- **Six-hour ceiling.** `sightingCeiling = 6 * defaultRest`. However many - **Six-rest ceiling.** `sightingCeilingRests = 6`, counted in the Site's own
Sightings arrive, a Series unpolled for six hours is Polled. Rest — six hours everywhere today. However many Sightings arrive, a Series
unpolled that long is Polled.
Both live in the due query's HAVING clause (`store.DueForLatestCheck`), beside Both live in the due query's HAVING clause (`store.DueForLatestCheck`), beside
the Rest cutoff — the same place the schedule has always been decided, so no the Rest cutoff — the same place the schedule has always been decided, so no
@@ -31,7 +32,9 @@ Attribution and judgement:
publishes against what is stored, so judgement costs no extra request: a lower publishes against what is stored, so judgement costs no extra request: a lower
number contradicts the Sighting (`sighting_disagreements + 1`, both numbers and number contradicts the Sighting (`sighting_disagreements + 1`, both numbers and
the Reader logged), the same number confirms it (`sighting_agreements + 1`), a the Reader logged), the same number confirms it (`sighting_agreements + 1`), a
**higher** number is the Site publishing and means nothing either way. **higher** number is the Site publishing and means nothing either way — but it
does clear the attribution (`Store.ClearSightingAttribution`), because the
value stored afterwards is the Poll's own and nobody must answer for it.
- At `SightingDisagreementLimit` (3) that Reader's Sightings stop deferring - At `SightingDisagreementLimit` (3) that Reader's Sightings stop deferring
anything. They still write the Latest Chapter — the penalty removes a anything. They still write the Latest Chapter — the penalty removes a
privilege, it does not silence anyone. privilege, it does not silence anyone.
@@ -40,12 +43,20 @@ Attribution and judgement:
- The owner clears marks from the administration page (issue #102, shipped - The owner clears marks from the administration page (issue #102, shipped
first precisely so a false mark has a remedy the day the mechanism lands). first precisely so a false mark has a remedy the day the mechanism lands).
One client change was required, and only one. Both userscripts stopped short of
PUTting a read whose number had not moved (`applyLatestChapterIfChanged`), so
the case this whole mechanism exists for — visiting a Series with nothing new —
never reached the backend. `reportLatestChapter` now sends it, skipping only the
local write and the re-render. A numberless PUT (favourite toggle, progress from
a chapter page) is not a Sighting and defers nothing: nobody read the Series
page, so there would be nothing to judge later.
## Why ## Why
Most of the backend's work was redundant. The userscript reads the Latest Most of the backend's work was redundant. The userscript reads the Latest
Chapter on every Series page visit and PUTs it; minutes later the Poll Lane Chapter on every Series page visit; minutes later the Poll Lane fetches the same
fetches the same page for the same number. Deferring on a report converts a page for the same number. Deferring on a report converts a visit into a Poll
visit into a Poll saved, which is Lane capacity handed back to Series nobody is saved, which is Lane capacity handed back to Series nobody is
reading. reading.
The restriction is the whole safety argument, and it is about **blast radius**, The restriction is the whole safety argument, and it is about **blast radius**,
@@ -72,14 +83,31 @@ therefore not a strategy, and credit cannot be banked in advance.
- **Trusting a Sighting on a shared Series** rejected: it is the only case where - **Trusting a Sighting on a shared Series** rejected: it is the only case where
one Reader's mistake reaches another Reader's list, and no amount of one Reader's mistake reaches another Reader's list, and no amount of
reputation makes that recoverable within the six-hour window. reputation makes that recoverable within the six-hour window.
- **Cross-Reader agreement, voting, weighting, consensus scoring** rejected: a - **Cross-Reader agreement, voting, weighting, consensus scoring** rejected on
single-source report cannot have its confidence evaluated by comparison, and evidence: every truth-discovery method estimates source reliability by
with the typical two Readers a disagreement identifies nothing. The Poll is comparing sources on the same object, and the standard survey states outright
the only oracle in the system, so it is the only judge. that an object provided by very few sources cannot have its confidence
evaluated — Li, Gao, Meng, Li, Su, Zhao, Fan, Han, *A Survey on Truth
Discovery*, SIGMOD Record 45(1), 2016 (arXiv:1505.02463), §"Challenges" on
sparse sources. With the two Readers this backend actually has, a
disagreement is a coin flip. The Poll is an authoritative oracle, so it is
the only judge.
- **A randomised audit** (Poll a fraction of deferred Series) rejected in favour - **A randomised audit** (Poll a fraction of deferred Series) rejected in favour
of the fixed ceiling: a sampled audit makes the attacker's expected cost a of the fixed ceiling. Sampling an oracle against untrusted reports is the
probability, while a ceiling makes it a certainty, and a certainty is what gold-question technique from crowdsourcing quality control — Le, Edmonds,
makes the solitary-Series rule defensible in one sentence. Hester, Biewald, *Ensuring quality in crowdsourced search relevance
evaluation: the effects of training question distribution*, SIGIR 2010
Workshop on Crowdsourcing for Search Evaluation, which inserts known answers
sporadically and adjusts each worker's trust from them. The ceiling is the
same idea made deterministic: sampling prices an attack in expectation, a
guaranteed six-hour audit prices it as a certainty, which is what makes the
solitary-Series rule defensible in one sentence.
- **A trust *ratio*** (agreements over judgements, as that same gold-question
scheme uses) rejected for two thresholds: a ratio lets an attacker bank
credit first and spend it on lies later, and it needs the owner watching a
score to act. Three-and-twenty is a threshold both ways — a disagreement
resets the run to zero, so credit cannot be pre-bought, and recovery happens
without the owner in the loop.
- **Blocking a marked Reader's writes** rejected: the Latest Chapter they report - **Blocking a marked Reader's writes** rejected: the Latest Chapter they report
is still the best available value, and their Sightings must keep being judged is still the best available value, and their Sightings must keep being judged
or they could never earn the privilege back. or they could never earn the privilege back.
@@ -87,10 +115,15 @@ therefore not a strategy, and credit cannot be banked in advance.
not the thing that can be wrong. Naming the Reader and logging both numbers is not the thing that can be wrong. Naming the Reader and logging both numbers is
also what distinguishes a broken Site adapter (every Reader of that Site also what distinguishes a broken Site adapter (every Reader of that Site
contradicted at once) from one bad actor. contradicted at once) from one bad actor.
- **Timers or a background reputation job** rejected: deferral is decided from - **Timers or a background reputation job** rejected: deferral is recomputed
live facts every round — Bookmark count, sighting timestamp, the Reader's from live facts every round — Bookmark count and sighting timestamp — so a
marks — so a Series that gains a second Bookmark stops deferring at once, with Series that gains a second Bookmark stops deferring at once, with nothing to
nothing to invalidate. invalidate. The Reader's marks are the one input read earlier, when the
Sighting is recorded rather than when the round runs: a Reader who crosses
the threshold, or has their marks cleared, changes behaviour from their next
Sighting on, and the standing they already bought lasts out its rest. That is
bounded by one rest and costs one subselect instead of joining `readers` into
the due query on every round.
## Constraints preserved ## Constraints preserved
+2
View File
@@ -40,6 +40,7 @@
"38": "novel-logic.test.js", "38": "novel-logic.test.js",
"39": "UI Critique 2026-07-26A", "39": "UI Critique 2026-07-26A",
"40": "UI Critique 2026-07-26B", "40": "UI Critique 2026-07-26B",
"41": "Handler",
"42": "pgtest.go", "42": "pgtest.go",
"43": "Issue Tracker & Triage", "43": "Issue Tracker & Triage",
"44": "Ticket Workflow", "44": "Ticket Workflow",
@@ -151,6 +152,7 @@
"150": "Reviewer Subagent (opencode)", "150": "Reviewer Subagent (opencode)",
"151": "Finding Severity Rubric", "151": "Finding Severity Rubric",
"152": "Spec Compliance Review", "152": "Spec Compliance Review",
"154": "ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them",
"156": "ResponseWriter", "156": "ResponseWriter",
"161": "Why Use samber/oops", "161": "Why Use samber/oops",
"162": "singleflight Cache Stampede Prevention", "162": "singleflight Cache Stampede Prevention",
+40 -34
View File
@@ -1,16 +1,16 @@
# Graph Report - mangaBookmark (2026-08-16) # Graph Report - mangaBookmark (2026-08-16)
## Corpus Check ## Corpus Check
- 116 files · ~284,419 words - 119 files · ~290,953 words
- Verdict: corpus is large enough that graph structure adds value. - Verdict: corpus is large enough that graph structure adds value.
## Summary ## Summary
- 1701 nodes · 3463 edges · 219 communities (69 shown, 150 thin omitted) - 1736 nodes · 3597 edges · 221 communities (70 shown, 151 thin omitted)
- Extraction: 91% EXTRACTED · 9% INFERRED · 0% AMBIGUOUS · INFERRED: 326 edges (avg confidence: 0.77) - Extraction: 90% EXTRACTED · 10% INFERRED · 0% AMBIGUOUS · INFERRED: 361 edges (avg confidence: 0.78)
- Token cost: 0 input · 0 output - Token cost: 0 input · 0 output
## Graph Freshness ## Graph Freshness
- Built from commit: `58014eb8` - Built from commit: `56afb9f2`
- Run `git rev-parse HEAD` and compare to check if the graph is stale. - Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost). - Run `graphify update .` after code changes (no API cost).
@@ -55,6 +55,7 @@
- [[_COMMUNITY_novel-logic.test.js|novel-logic.test.js]] - [[_COMMUNITY_novel-logic.test.js|novel-logic.test.js]]
- [[_COMMUNITY_UI Critique 2026-07-26A|UI Critique 2026-07-26A]] - [[_COMMUNITY_UI Critique 2026-07-26A|UI Critique 2026-07-26A]]
- [[_COMMUNITY_UI Critique 2026-07-26B|UI Critique 2026-07-26B]] - [[_COMMUNITY_UI Critique 2026-07-26B|UI Critique 2026-07-26B]]
- [[_COMMUNITY_Handler|Handler]]
- [[_COMMUNITY_pgtest.go|pgtest.go]] - [[_COMMUNITY_pgtest.go|pgtest.go]]
- [[_COMMUNITY_Issue Tracker & Triage|Issue Tracker & Triage]] - [[_COMMUNITY_Issue Tracker & Triage|Issue Tracker & Triage]]
- [[_COMMUNITY_Ticket Workflow|Ticket Workflow]] - [[_COMMUNITY_Ticket Workflow|Ticket Workflow]]
@@ -166,6 +167,7 @@
- [[_COMMUNITY_Reviewer Subagent (opencode)|Reviewer Subagent (opencode)]] - [[_COMMUNITY_Reviewer Subagent (opencode)|Reviewer Subagent (opencode)]]
- [[_COMMUNITY_Finding Severity Rubric|Finding Severity Rubric]] - [[_COMMUNITY_Finding Severity Rubric|Finding Severity Rubric]]
- [[_COMMUNITY_Spec Compliance Review|Spec Compliance Review]] - [[_COMMUNITY_Spec Compliance Review|Spec Compliance Review]]
- [[_COMMUNITY_ADR-0011 Sightings — a Reader report defers a Poll where being wrong hurts only them|ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them]]
- [[_COMMUNITY_ResponseWriter|ResponseWriter]] - [[_COMMUNITY_ResponseWriter|ResponseWriter]]
- [[_COMMUNITY_Why Use samberoops|Why Use samber/oops]] - [[_COMMUNITY_Why Use samberoops|Why Use samber/oops]]
- [[_COMMUNITY_singleflight Cache Stampede Prevention|singleflight Cache Stampede Prevention]] - [[_COMMUNITY_singleflight Cache Stampede Prevention|singleflight Cache Stampede Prevention]]
@@ -235,16 +237,16 @@
- [[_COMMUNITY_Userscript CLAUDE guidance|Userscript CLAUDE guidance]] - [[_COMMUNITY_Userscript CLAUDE guidance|Userscript CLAUDE guidance]]
## God Nodes (most connected - your core abstractions) ## God Nodes (most connected - your core abstractions)
1. `testConfig()` - 54 edges 1. `newTestStore()` - 62 edges
2. `newTestStore()` - 49 edges 2. `testConfig()` - 55 edges
3. `newWebTestServer()` - 49 edges 3. `newWebTestServer()` - 49 edges
4. `newTestStore()` - 44 edges 4. `newTestStore()` - 44 edges
5. `e()` - 33 edges 5. `e()` - 33 edges
6. `Open()` - 30 edges 6. `Store` - 31 edges
7. `Handler` - 29 edges 7. `Open()` - 31 edges
8. `Store` - 28 edges 8. `newTestPoller()` - 30 edges
9. `ne()` - 28 edges 9. `Handler` - 29 edges
10. `De()` - 28 edges 10. `ne()` - 28 edges
## Surprising Connections (you probably didn't know these) ## Surprising Connections (you probably didn't know these)
- `el()` --indirect_call--> `c()` [INFERRED] - `el()` --indirect_call--> `c()` [INFERRED]
@@ -268,7 +270,7 @@
- **Headless browser infrastructure (sidecar, on-demand, home deployment)** — docs_adr_0005_on_demand_browser_headless_shell, docs_adr_0005_on_demand_browser_cdp, docs_adr_0005_on_demand_browser_on_demand_start, docs_adr_0006_browser_on_the_home_machine_home_machine_rationale [INFERRED 0.85] - **Headless browser infrastructure (sidecar, on-demand, home deployment)** — docs_adr_0005_on_demand_browser_headless_shell, docs_adr_0005_on_demand_browser_cdp, docs_adr_0005_on_demand_browser_on_demand_start, docs_adr_0006_browser_on_the_home_machine_home_machine_rationale [INFERRED 0.85]
- **lightnovelworld series-identity investigation and fix** — docs_research_lightnovelworld_chapter_vs_series_slug_issue_77, docs_research_lightnovelworld_chapter_vs_series_slug_unscoped_regex, docs_adr_0008_series_identity_is_discovered_not_derived_discovered_identity [INFERRED 0.85] - **lightnovelworld series-identity investigation and fix** — docs_research_lightnovelworld_chapter_vs_series_slug_issue_77, docs_research_lightnovelworld_chapter_vs_series_slug_unscoped_regex, docs_adr_0008_series_identity_is_discovered_not_derived_discovered_identity [INFERRED 0.85]
## Communities (219 total, 150 thin omitted) ## Communities (221 total, 151 thin omitted)
### Community 0 - "HTMX Library Internals" ### Community 0 - "HTMX Library Internals"
Cohesion: 0.08 Cohesion: 0.08
@@ -280,23 +282,23 @@ Nodes (89): floatPtr(), testConfig(), getCover(), Cookie, Handler, ResponseRecor
### Community 2 - "Manga Userscript Adapters" ### Community 2 - "Manga Userscript Adapters"
Cohesion: 0.06 Cohesion: 0.06
Nodes (76): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLatestChapterIfChanged() (+68 more) Nodes (77): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), armDwell() (+69 more)
### Community 3 - "Novel Userscript Adapters" ### Community 3 - "Novel Userscript Adapters"
Cohesion: 0.06 Cohesion: 0.06
Nodes (79): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLatestChapterIfChanged() (+71 more) Nodes (78): adapterFor(), anchorsFromDocument(), anchorsFromHTML(), apiDelete(), apiGet(), apiPut(), applyFabPos(), applyLnwStaleRowRepair() (+70 more)
### Community 4 - "Series Acquisition Tests" ### Community 4 - "Series Acquisition Tests"
Cohesion: 0.08 Cohesion: 0.07
Nodes (78): bookmarkNewKaganeSeries(), bookmarkNewNovelfullSeries(), bookmarkNewSeries(), Context, Store, T, newAcquirer(), readBookmark() (+70 more) Nodes (103): bookmarkNewKaganeSeries(), bookmarkNewNovelfullSeries(), bookmarkNewSeries(), Context, Store, T, newAcquirer(), readBookmark() (+95 more)
### Community 5 - "Bookmarks API Tests" ### Community 5 - "Bookmarks API Tests"
Cohesion: 0.07 Cohesion: 0.07
Nodes (70): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+62 more) Nodes (71): auth(), getBookmarks(), Handler, Request, Store, T, newTestServer(), newTestStore() (+63 more)
### Community 7 - "Cover & Acquire Internals" ### Community 7 - "Cover & Acquire Internals"
Cohesion: 0.07 Cohesion: 0.06
Nodes (43): Addr, fakeLanes, Context, Store, WaitGroup, defaultCoverResolver(), fetchCoverBytes(), Client (+35 more) Nodes (43): Addr, fakeLanes, Context, Store, WaitGroup, isInterstitial(), defaultCoverResolver(), fetchCoverBytes() (+35 more)
### Community 8 - "System Architecture Concepts" ### Community 8 - "System Architecture Concepts"
Cohesion: 0.15 Cohesion: 0.15
@@ -318,10 +320,6 @@ Nodes (45): Store, T, newTestStore(), readLatestCheckedAt(), readSeries(), secon
Cohesion: 0.08 Cohesion: 0.08
Nodes (32): Handler, Request, ResponseWriter, Store, Healthz(), writeJSON(), Auth(), compressible() (+24 more) Nodes (32): Handler, Request, ResponseWriter, Store, Healthz(), writeJSON(), Auth(), compressible() (+24 more)
### Community 13 - "Web UI Handlers"
Cohesion: 0.14
Nodes (18): currentLib(), currentTab(), filterBookmarks(), Client, HandlerFunc, Request, ResponseWriter, Store (+10 more)
### Community 14 - "Go Error Handling" ### Community 14 - "Go Error Handling"
Cohesion: 0.06 Cohesion: 0.06
Nodes (33): Creating Errors, Custom Error Types, Custom types that wrap other errors, Decision table: which error strategy to use, Error Creation, Error String Conventions, Errors as Values, `errors.New` — static error messages (+25 more) Nodes (33): Creating Errors, Custom Error Types, Custom types that wrap other errors, Decision table: which error strategy to use, Error Creation, Error String Conventions, Errors as Values, `errors.New` — static error messages (+25 more)
@@ -368,7 +366,7 @@ Nodes (12): coverResponse(), Request, T, TestCoverFetcherCanonicalisesJpgAlias()
### Community 26 - "Open" ### Community 26 - "Open"
Cohesion: 0.05 Cohesion: 0.05
Nodes (62): awaitPromise(), browserConnectionLost(), classifyBrowserError(), comixRead(), comixSeriesPageURL(), Action, Context, Mutex (+54 more) Nodes (61): awaitPromise(), browserConnectionLost(), classifyBrowserError(), comixRead(), comixSeriesPageURL(), Action, Context, Mutex (+53 more)
### Community 27 - "Find Skills Guide" ### Community 27 - "Find Skills Guide"
Cohesion: 0.14 Cohesion: 0.14
@@ -387,8 +385,8 @@ Cohesion: 0.40
Nodes (5): Map of pointers for large, frequently updated structs, Memory Layout, Pointer receivers for large structs, Struct field alignment, Zero-size field at end of struct Nodes (5): Map of pointers for large, frequently updated structs, Memory Layout, Pointer receivers for large structs, Struct field alignment, Zero-size field at end of struct
### Community 31 - "Store" ### Community 31 - "Store"
Cohesion: 0.08 Cohesion: 0.09
Nodes (8): coverRelativePath(), coverSourceAddress(), displayChapter(), Store, scanSeries(), TestDisplayChapter(), Bookmark, ReaderSummary Nodes (5): coverRelativePath(), coverSourceAddress(), Store, scanSeries(), ReaderSummary
### Community 33 - "Go Testing Guide" ### Community 33 - "Go Testing Guide"
Cohesion: 0.20 Cohesion: 0.20
@@ -418,6 +416,10 @@ Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations,
Cohesion: 0.29 Cohesion: 0.29
Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations, Persona Red Flags, Priority Issues, Questions to Consider Nodes (6): Design Health Score, Design Specificity Verdict, Minor Observations, Persona Red Flags, Priority Issues, Questions to Consider
### Community 41 - "Handler"
Cohesion: 0.14
Nodes (18): currentLib(), currentTab(), filterBookmarks(), Client, HandlerFunc, Request, ResponseWriter, Store (+10 more)
### Community 42 - "pgtest.go" ### Community 42 - "pgtest.go"
Cohesion: 0.18 Cohesion: 0.18
Nodes (12): M, TestMain(), M, TestMain(), M, Main(), start(), URL() (+4 more) Nodes (12): M, TestMain(), M, TestMain(), M, Main(), start(), URL() (+4 more)
@@ -534,6 +536,10 @@ Nodes (3): ADR-0005: On-demand browser sidecar, Constraints, Decision
Cohesion: 0.48 Cohesion: 0.48
Nodes (6): T, TestCreateAndGetSession(), TestDeleteSessionIsPerReader(), TestDeleteSessionRevokes(), TestExpiredSessionIsGone(), TestGetSessionUnknownID() Nodes (6): T, TestCreateAndGetSession(), TestDeleteSessionIsPerReader(), TestDeleteSessionRevokes(), TestExpiredSessionIsGone(), TestGetSessionUnknownID()
### Community 154 - "ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them"
Cohesion: 0.33
Nodes (5): ADR-0011: Sightings — a Reader report defers a Poll where being wrong hurts only them, Constraints preserved, Decision, Tradeoffs and rejections, Why
### Community 156 - "ResponseWriter" ### Community 156 - "ResponseWriter"
Cohesion: 0.18 Cohesion: 0.18
Nodes (13): AdminPatterns(), HandlerFunc, Request, ResponseWriter, Time, Handler, readerPathID(), since() (+5 more) Nodes (13): AdminPatterns(), HandlerFunc, Request, ResponseWriter, Time, Handler, readerPathID(), since() (+5 more)
@@ -543,24 +549,24 @@ Cohesion: 0.50
Nodes (3): Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust), Second script: `novel-bookmark.user.js`, Userscript structure (single IIFE, `manga-bookmark.user.js`) Nodes (3): Live URL shapes (verified 2026-07-26, may drift — re-check against live pages before trust), Second script: `novel-bookmark.user.js`, Userscript structure (single IIFE, `manga-bookmark.user.js`)
## Knowledge Gaps ## Knowledge Gaps
- **520 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+515 more) - **524 isolated node(s):** `bookmarkmanager/backend`, `ctxKey`, `loginView`, `ctxKey`, `test` (+519 more)
These have ≤1 connection - possible missing edges or undocumented components. These have ≤1 connection - possible missing edges or undocumented components.
- **150 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. - **151 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions ## Suggested Questions
_Questions this graph is uniquely positioned to answer:_ _Questions this graph is uniquely positioned to answer:_
- **Why does `Open()` connect `CDP Browser Client` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Tests`, `pgtest.go`, `Store Tests`, `Store`?** - **Why does `Open()` connect `CDP Browser Client` to `Cover Fetch Test Helpers`, `Series Acquisition Tests`, `Bookmarks API Tests`, `pgtest.go`, `Store Tests`, `Store`?**
_High betweenness centrality (0.060) - this node is a cross-community bridge._
- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Handler`, `Session Middleware`, `CDP Browser Client`, `ResponseWriter`?**
_High betweenness centrality (0.050) - this node is a cross-community bridge._ _High betweenness centrality (0.050) - this node is a cross-community bridge._
- **Why does `New()` connect `Series Acquisition Tests` to `Bookmarks API Tests`, `Cover & Acquire Internals`, `Session Middleware`, `Web UI Handlers`, `CDP Browser Client`, `ResponseWriter`?**
_High betweenness centrality (0.048) - this node is a cross-community bridge._
- **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Bookmarks API Handler`, `Series Acquisition Tests`, `ResponseWriter`?** - **Why does `newRouter()` connect `Bookmarks API Tests` to `Cover Fetch Test Helpers`, `Bookmarks API Handler`, `Series Acquisition Tests`, `ResponseWriter`?**
_High betweenness centrality (0.031) - this node is a cross-community bridge._ _High betweenness centrality (0.030) - this node is a cross-community bridge._
- **Are the 25 inferred relationships involving `newTestStore()` (e.g. with `TestAcquireDoesNotBlockTheWrite()` and `TestAcquireFailureLeavesTheBookmarkIntact()`) actually correct?**
_`newTestStore()` has 25 INFERRED edges - model-reasoned connections that need verification._
- **Are the 48 inferred relationships involving `testConfig()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverNeverEchoesNonImage()`) actually correct?** - **Are the 48 inferred relationships involving `testConfig()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverNeverEchoesNonImage()`) actually correct?**
_`testConfig()` has 48 INFERRED edges - model-reasoned connections that need verification._ _`testConfig()` has 48 INFERRED edges - model-reasoned connections that need verification._
- **Are the 12 inferred relationships involving `newTestStore()` (e.g. with `TestAcquireDoesNotBlockTheWrite()` and `TestAcquireFailureLeavesTheBookmarkIntact()`) actually correct?**
_`newTestStore()` has 12 INFERRED edges - model-reasoned connections that need verification._
- **Are the 8 inferred relationships involving `newWebTestServer()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverRejectsUnknownAddress()`) actually correct?** - **Are the 8 inferred relationships involving `newWebTestServer()` (e.g. with `TestListRendersAcquiredCover()` and `TestPublicCoverRejectsUnknownAddress()`) actually correct?**
_`newWebTestServer()` has 8 INFERRED edges - model-reasoned connections that need verification._ _`newWebTestServer()` has 8 INFERRED edges - model-reasoned connections that need verification._
- **What connects `bookmarkmanager/backend`, `ctxKey`, `loginView` to the rest of the system?** - **What connects `bookmarkmanager/backend`, `ctxKey`, `loginView` to the rest of the system?**
_552 weakly-connected nodes found - possible documentation gaps or missing edges._ _556 weakly-connected nodes found - possible documentation gaps or missing edges._
File diff suppressed because one or more lines are too long
+2654 -859
View File
File diff suppressed because it is too large Load Diff
+36 -21
View File
@@ -35,8 +35,8 @@
"semantic_hash": "4a94ac85bad6bce330d085bcc0ae3ffd" "semantic_hash": "4a94ac85bad6bce330d085bcc0ae3ffd"
}, },
"userscript/manga-bookmark.user.js": { "userscript/manga-bookmark.user.js": {
"mtime": 1786499529.779988, "mtime": 1786872888.3482232,
"ast_hash": "1f8bcddd3632d709f058a8401af8f127", "ast_hash": "4e79684942234b26f4a1fb03fccd6d35",
"semantic_hash": "" "semantic_hash": ""
}, },
".agents/skills/find-skills/SKILL.md": { ".agents/skills/find-skills/SKILL.md": {
@@ -225,8 +225,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/api_test.go": { "backend/api_test.go": {
"mtime": 1786863966.7056258, "mtime": 1786868080.2317674,
"ast_hash": "0d443b4fe3c04ab057505d73b8a88d07", "ast_hash": "b64372df5429fb14e48b37b3bab10402",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/cover_test.go": { "backend/cover_test.go": {
@@ -235,9 +235,9 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/internal/api/handlers.go": { "backend/internal/api/handlers.go": {
"mtime": 1786363889.552731, "mtime": 1786867745.788687,
"ast_hash": "59e6b8767ab19839bb8f82891a7e4616", "ast_hash": "ae4f3b961450f06a06e0632eb273a5b2",
"semantic_hash": "59e6b8767ab19839bb8f82891a7e4616" "semantic_hash": ""
}, },
"backend/internal/httpmw/middleware.go": { "backend/internal/httpmw/middleware.go": {
"mtime": 1786216141.672644, "mtime": 1786216141.672644,
@@ -260,8 +260,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/internal/latest/poller.go": { "backend/internal/latest/poller.go": {
"mtime": 1786865046.096795, "mtime": 1786872774.641225,
"ast_hash": "2bfb51177e3df474b80ea1b05523377e", "ast_hash": "5e55e2ac5cb7d347807754f92425d29b",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/internal/latest/poller_test.go": { "backend/internal/latest/poller_test.go": {
@@ -270,8 +270,8 @@
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/internal/latest/sites.go": { "backend/internal/latest/sites.go": {
"mtime": 1786861012.026504, "mtime": 1786868771.0663204,
"ast_hash": "9b90f9b710ccbd0d0a0a6a0dd721ff3c", "ast_hash": "5786000dd0dfd5b4b3bd2b87b1fc100b",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/internal/latest/sites_test.go": { "backend/internal/latest/sites_test.go": {
@@ -340,13 +340,13 @@
"semantic_hash": "0b6764a0ee20f5cb7748eecd31a1d220" "semantic_hash": "0b6764a0ee20f5cb7748eecd31a1d220"
}, },
"backend/internal/store/store.go": { "backend/internal/store/store.go": {
"mtime": 1786863704.5112119, "mtime": 1786868763.646761,
"ast_hash": "989629af18232e35b12f1eef2a8cb422", "ast_hash": "34f55617b7409f03f02e925bec92fac0",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/internal/store/store_test.go": { "backend/internal/store/store_test.go": {
"mtime": 1786863712.075631, "mtime": 1786867781.301985,
"ast_hash": "e48f21e34238ba46874cc88cf96e022f", "ast_hash": "16f69d97eb63bcec7409294466642411",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/internal/userscript/userscript.go": { "backend/internal/userscript/userscript.go": {
@@ -395,8 +395,8 @@
"semantic_hash": "8008a187690764436540fab47ba0cfcc" "semantic_hash": "8008a187690764436540fab47ba0cfcc"
}, },
"userscript/novel-bookmark.user.js": { "userscript/novel-bookmark.user.js": {
"mtime": 1786499529.779988, "mtime": 1786872902.809149,
"ast_hash": "834effb0821f8d6c9f57f6554a5db462", "ast_hash": "36c81462919719f0e053ca5d633a243a",
"semantic_hash": "" "semantic_hash": ""
}, },
"userscript/test/novel-logic.test.js": { "userscript/test/novel-logic.test.js": {
@@ -425,13 +425,13 @@
"semantic_hash": "6c6f3e4c4c2f57867894280bce728c50" "semantic_hash": "6c6f3e4c4c2f57867894280bce728c50"
}, },
"backend/AGENTS.md": { "backend/AGENTS.md": {
"mtime": 1786865319.314815, "mtime": 1786872951.3127444,
"ast_hash": "5bbabcd7dd425302cb0d989d8897baf6", "ast_hash": "68bc86af9593adad1e37fa05188fe63e",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/CLAUDE.md": { "backend/CLAUDE.md": {
"mtime": 1786865319.314815, "mtime": 1786872951.3127444,
"ast_hash": "5bbabcd7dd425302cb0d989d8897baf6", "ast_hash": "68bc86af9593adad1e37fa05188fe63e",
"semantic_hash": "" "semantic_hash": ""
}, },
"backend/internal/web/templates/app.html": { "backend/internal/web/templates/app.html": {
@@ -658,5 +658,20 @@
"mtime": 1786865113.5362902, "mtime": 1786865113.5362902,
"ast_hash": "8b9f9c7a56e0ec3bf950aa70acd95e92", "ast_hash": "8b9f9c7a56e0ec3bf950aa70acd95e92",
"semantic_hash": "" "semantic_hash": ""
},
"backend/internal/latest/sighting_test.go": {
"mtime": 1786868815.9760568,
"ast_hash": "3b1372bbeeb538b73f86f57da210b4cc",
"semantic_hash": ""
},
"backend/internal/store/migrations/0011_series_sightings.sql": {
"mtime": 1786867605.3728056,
"ast_hash": "d0ade928a6e951eb179e4d0255cca526",
"semantic_hash": ""
},
"docs/adr/0011-sighting-deferral-trust-model.md": {
"mtime": 1786872970.473592,
"ast_hash": "0b6ca704f62185c9d629320f2d8475b5",
"semantic_hash": ""
} }
} }
+24 -14
View File
@@ -905,27 +905,37 @@
} }
} }
// Records the newest chapter a site has published. Silent: this fires from // Reports the newest chapter a site has published. Silent: this fires from
// page visits and background checks the user did not ask for, and it never // page visits and background checks the user did not ask for, and it never
// reorders the list — updated_at is a candidate the server discards unless // reorders the list — updated_at is a candidate the server discards unless
// reading progress moved. // reading progress moved.
async function applyLatestChapterIfChanged(existing, latest) { //
// An unchanged number is still sent. It is the read that lets the backend
// skip its own poll of this series (a Sighting, issue #103), so the common
// case — visiting a series with nothing new — is exactly the one worth
// reporting. Only the local write and the re-render are skipped.
async function reportLatestChapter(existing, latest) {
if (!existing || !latest) return; if (!existing || !latest) return;
if (existing.latest_chapter_num === latest.num) return; const changed = existing.latest_chapter_num !== latest.num;
const bm = Object.assign({}, existing, { let bm = existing;
latest_chapter: latest.label, if (changed) {
latest_chapter_num: latest.num, bm = Object.assign({}, existing, {
updated_at: Date.now(), latest_chapter: latest.label,
}); latest_chapter_num: latest.num,
upsertLocal(bm); updated_at: Date.now(),
render(); });
upsertLocal(bm);
render();
}
// A queued write owns this row; the drain sends latest_chapter // A queued write owns this row; the drain sends latest_chapter
// with it, carrying the correct bucket. // with it, carrying the correct bucket.
if (queueGet(bm.key)) return; if (queueGet(bm.key)) return;
try { try {
const saved = await apiPut(bm.key, bm); const saved = await apiPut(bm.key, bm);
upsertLocal(saved); if (changed) {
render(); upsertLocal(saved);
render();
}
} catch (e) { } catch (e) {
/* offline — the local cache still shows it, retried on a later visit */ /* offline — the local cache still shows it, retried on a later visit */
} }
@@ -937,7 +947,7 @@
if (p.type !== "series") return; if (p.type !== "series") return;
const existing = state.byKey[keyOf(p)]; const existing = state.byKey[keyOf(p)];
if (!existing) return; if (!existing) return;
applyLatestChapterIfChanged( reportLatestChapter(
existing, existing,
computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId) computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId)
); );
@@ -977,7 +987,7 @@
const html = await res.text(); const html = await res.text();
latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id); latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id);
} }
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest); await reportLatestChapter(state.byKey[bm.key] || bm, latest);
} catch (e) { } catch (e) {
/* offline or blocked — try again after the throttle window */ /* offline or blocked — try again after the throttle window */
} }
+24 -14
View File
@@ -809,27 +809,37 @@
} }
} }
// Records the newest chapter a site has published. Silent: this fires from // Reports the newest chapter a site has published. Silent: this fires from
// page visits and background checks the user did not ask for, and it never // page visits and background checks the user did not ask for, and it never
// reorders the list — updated_at is a candidate the server discards unless // reorders the list — updated_at is a candidate the server discards unless
// reading progress moved. // reading progress moved.
async function applyLatestChapterIfChanged(existing, latest) { //
// An unchanged number is still sent. It is the read that lets the backend
// skip its own poll of this series (a Sighting, issue #103), so the common
// case — visiting a series with nothing new — is exactly the one worth
// reporting. Only the local write and the re-render are skipped.
async function reportLatestChapter(existing, latest) {
if (!existing || !latest) return; if (!existing || !latest) return;
if (existing.latest_chapter_num === latest.num) return; const changed = existing.latest_chapter_num !== latest.num;
const bm = Object.assign({}, existing, { let bm = existing;
latest_chapter: latest.label, if (changed) {
latest_chapter_num: latest.num, bm = Object.assign({}, existing, {
updated_at: Date.now(), latest_chapter: latest.label,
}); latest_chapter_num: latest.num,
upsertLocal(bm); updated_at: Date.now(),
render(); });
upsertLocal(bm);
render();
}
// A queued write owns this row; the drain sends latest_chapter // A queued write owns this row; the drain sends latest_chapter
// with it, carrying the correct bucket. // with it, carrying the correct bucket.
if (queueGet(bm.key)) return; if (queueGet(bm.key)) return;
try { try {
const saved = await apiPut(bm.key, bm); const saved = await apiPut(bm.key, bm);
upsertLocal(saved); if (changed) {
render(); upsertLocal(saved);
render();
}
} catch (e) { } catch (e) {
/* offline — the local cache still shows it, retried on a later visit */ /* offline — the local cache still shows it, retried on a later visit */
} }
@@ -841,7 +851,7 @@
if (p.type !== "series") return; if (p.type !== "series") return;
const existing = state.byKey[keyOf(p)]; const existing = state.byKey[keyOf(p)];
if (!existing) return; if (!existing) return;
applyLatestChapterIfChanged( reportLatestChapter(
existing, existing,
computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId) computeLatestChapter(p.site, anchorsFromDocument(document), p.seriesId)
); );
@@ -883,7 +893,7 @@
if (!res.ok) continue; if (!res.ok) continue;
const html = await res.text(); const html = await res.text();
const latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id); const latest = computeLatestChapter(bm.site, anchorsFromHTML(html), bm.series_id);
await applyLatestChapterIfChanged(state.byKey[bm.key] || bm, latest); await reportLatestChapter(state.byKey[bm.key] || bm, latest);
} catch (e) { } catch (e) {
/* offline or blocked — try again after the throttle window */ /* offline or blocked — try again after the throttle window */
} }