grain-serve scores what arrives with the classifiers, on Clef
MergedThis pull scores new records and their authors with the Clef classifiers off the request path. It files reports only, and labels bridged accounts' posts spam without a model.
I read index/classify.rs and classify/mod.rs in full, and the call sites of Index::arrived in the Jetstream, indexRecords and web-write paths. I did not read the whole diff, which was too large to open here. That leaves clef.rs, classifiers.rs, the V9 migration, the env parsing and the tests unreviewed. I did not check whether CI passed.
- Arrivals: all three paths call
arrivedonly onApplied::Created, so updates and the web write followed by Jetstream don't score twice. - Child-safety gate:
may_classifyis checked at the start of everyscore, before any image fetch, and covers labels, self-labels and reports on the account and its records. Negated or resolved entries still block, as the description says. - Retries: the semaphore permit is released during backoff, so a failing subject doesn't hold up the other three scoring slots.
report_onceandlabel_once: each is a singleINSERT … WHERE NOT EXISTS, so neither check-then-insert can race.
Two small things, neither blocking:
- A CSAE report or label that lands while a request is already in flight can't stop that request.
scorepanics throughexpect("a collection classified")if a covered collection has no table. This is safe for the current collections, but it runs in a spawned task, so the panic would only kill that one task.
The new commit makes the bridged check fall back to the PDS in the account's DID document. That fixes the new-account case, but the fallback runs in the wrong place.
Blocking: bridged() awaits a network fetch inside the queue's drain loop.
- In
run,self.bridged(&uri, did).awaitruns once per gallery, story or comment, one record at a time, before the next arrival is read. - For every author the handle and profile check doesn't flag, which is nearly all of them, it now calls
kept_document. That is a PLC ordid:webrequest unless the DID was read within the last 5 minutes. ctx.httphas a 60 s timeout, so a slow or failing PLC directory can hold up all of it, including the scoring dispatch below it, for up to a minute per record. The 10,000-slot queue then fills and arrivals are dropped, which is the overflow path the description says is not retried.- Before this commit that call was a local SQLite read, so this is a new cost on the hot loop.
- Move the labeling into a spawned task, as
queuedoes for scoring. Alternatively, resolve the DID only when the cheap check fails and do it off the loop.
Related: a failed document fetch is never retried. bridged returns the error, the loop logs a warning, and that bridged post is left unlabeled. queue's backoff doesn't cover this path. If you spawn it, run it under the same retry.
The test covers the hosted, unknown and non-bridged cases. It doesn't cover a slow or hanging directory holding up later arrivals, which is the behaviour to pin once this is fixed.
I didn't check CI. All four runs for this commit were still queued.
The two non-blocking notes from round 1 are unchanged.
crates/grain-serve/src/classify/mod.rsline 134Outdated
continue; | |
}; | |
if BRIDGED_HIDDEN.contains(&collection) | |
&& let Err(e) = self.bridged(&uri, did).await |
This awaits a possible PLC fetch (up to the 60 s client timeout) inside the single drain loop, so one slow lookup delays every later arrival and the scoring dispatch. Spawn it, with retry, instead of awaiting it here.
crates/grain-serve/src/classify/mod.rsline 214Outdated
}; | |
let bridged = match self.index.read(|db| classifiers::bridged(db, did))? { | |
true => true, | |
false => { |
Most authors are not bridged, so almost every post now reaches kept_document. A fetch error here only logs a warning and the post is never labeled.
ba2916a moves the bridged check off the drain loop: each gallery, story or comment gets its own task, run under the same retry as scoring (tried, shared by both). The test adds a DID whose document never answers, posted first, and a DID whose document fails once: everything after the hanging one is labeled, and the flaky one is labeled on its second try. The non-blocking notes: the in-flight CSAE race is now listed under Shortcomings. The expect stays, because every collection in ALL has a table, and the unit tests pin covers.
The new commit does what round 2 asked. run now calls bridged, which returns at once and spawns a task. That task runs label_bridged under the same tried backoff that scoring uses. A slow or hanging DID directory no longer holds up the drain loop or the scoring dispatch below it. A failed document fetch is retried, and only warns after the attempts run out. The check order is unchanged: the local handle and profile read first, then the DID document, then label_once, which is a single idempotent insert.
I read classify/mod.rs around the change. I did not re-read the tests or check CI.
Non-blocking: nothing limits how many bridged tasks run at once. In a burst of galleries, stories or comments from accounts not yet cached, each spawns its own DID document fetch. kept_document caches for 5 minutes, but concurrent misses for the same DID could each fetch. The ctx.http timeout bounds each task, so this is not a hang. A small semaphore or per-DID dedup would be tidier if it shows up in practice.
The two notes from round 1 are unchanged.
Carried forward: the change this pull makes at 4271ae5bb3eea4d07f78f253ef7f890b974dd3a5 is the one reviewed at ba2916a2bc652fa1dd2b2b548e60dd6a16c3580b, so that review stands. What is new comes from its base.
The new commit only merges feature/rust into the branch. The push sender took V9 and Index::arrived, so this pull's migration is renumbered to V10. arrived now tells the classifiers through their own sender, classify_to, and then the push sender. Each sender has its own queue, so one that falls behind drops only its own arrivals.
I checked that MIGRATIONS lists V1 to V10 in order, and that classify_to and the classifying OnceLock are wired as described. I did not re-read the rest of the merge or look at CI.
The review I carried forward from the last round still stands, as do its non-blocking notes: bridged tasks are not limited in number, and a csae flag that lands while a request is already with Clef does not stop that request.
The new commit only merges feature/rust into the branch. What it brings in is the separate OpenGraph cards change (og/, fonts, tests/og.rs), which doesn't touch the classifier code. I didn't re-read the merge beyond the log, and I didn't verify that it merges without semantic conflicts. All four CI runs for this commit were still queued, so CI has not confirmed it.
The earlier approval stands, along with its non-blocking notes: bridged tasks are not limited in number, and a csae flag that lands while a request is already with Clef does not stop that request.
- grain-serve scores what arrives with the classifiers, on Clef
- main

What changes
What arrives is scored, off every request's path. The three live paths that put records (the Jetstream,
indexRecordsand the web's writes) say which record was new to the index (Index::arrived), into a bounded queue (10,000) thatclassify::Classifydrains, four subjects at a time. A record is scored by the classifiers of its collection:gallery-spam(galleries) andnsfw(photos). Its author is scored by the account classifiers,impersonation,scamandspam, once they have written nothing for a minute, so an import of hundreds of photos asks about the account once. Backfill and the import do not say what they put: nothing that existed before is scored. A subject that fails is tried again with backoff, five times, then dropped with a warning.Scoring builds the state each classifier shows Clef (the questions, thresholds, states and gates are the old server's, copied), skips it when there is none (a gallery with under 8 characters of text, an account with under 5 galleries for
spam) or when its fingerprint (classifier version and state, SHA-256) matches the last one scored, and otherwise asks Clef and keeps the scores inclassification(migration V10). A signal at or past its threshold files a report, bysystem:<classifier>, asking for the label the old server asked for (spam,impersonation,nudity,sexual,other), unless a report on that subject asking for that label is already open. Nothing else acts on a score: a person decides.Clef (
classify::clef) is Workers AI's@cf/cloudflare/clef, posted{state, model, questions, images?}with a bearer token. 408, 429, 5xx and connection failures are tried again (4 tries, from 500 ms, doubling, or afterretry-after); anything else is final, andsuccess: falseis an error. Every answer type is made a 0..1 score, as before.Images are the CDN's
feed_thumbnailof the photo's blob, fetched by grain-serve, made at most 512 px on the longer side and JPEG (q80), and sent inline, at most four a request.Child safety. Nothing of an account is shown to a model when any label, self-label or report, negated or resolved since or not, ever said
csaeabout the account or any of its records (Index::may_classify). Taken-down accounts and those gone from their host are not scored either.Bridged accounts' posts are labeled
spamas they arrive (galleries, stories, comments), by grain's own labeler (the service DID fromGRAIN_PUBLIC_URL; without one, none), when the handle ends.brid.gy, the profile carries Bridgy Fed's footer, or else the PDS the DID document names is Bridgy Fed's (an account new to the index has no handle there yet, and its profile may come after its first post). A post that ever had aspamlabel, negated since or not, is left alone, so a moderator who took one off is not overruled. This asks no model and runs without Clef.Why
The classifier step of the plan. The design keeps the old server's questions and thresholds, which were tuned on
clef, and its rule that a classifier only files reports. The settle delay replaces scoring an account on every one of its records.Differences from the old server:
classifiers:sincecursor, and ran a catch-up scan over everything new since then at startup, for what a restart dropped from its queue. Here backfill and the import say nothing, and a restart drops what was queued. The stream replays from its cursor, so what it brings after a restart still arrives; accounts onatproto.brid.gyare not backfilled at all, so their posts come by the stream.IMGPROXY_ONLY_PRESETSis on, and the presets are avatar, feed_thumbnail, feed_fullsize and their_ogtwins), so the thumbnail is shrunk in-process withimagerather than asked of imgproxy at 512.csaeto Clef.classificationis not imported: it is re-derivable by scoring again (at Clef's cost), and the old reports, which are not, are already carried by the import's_reports.Env vars:
CLOUDFLARE_ACCOUNT_IDandCLOUDFLARE_AI_TOKEN(both, or no Clef),GRAIN_CLEF_MODEL(defaultclef),GRAIN_CLEF_URL(default Workers AI's URL for the account and model; tests point it elsewhere).CLOUDFLARE_API_TOKENis never read. The token is never logged or in an error (Config's Debug leaves it out).How it was checked
tests/classify.rsruns against a fake Clef that records each request and a CDN serving large PNGs: a photo past its threshold files one report with the right label, reason, reporter and a 512 px JPEG inline; the same state is not asked again, and a changed one is but files no second report; under-threshold scores file nothing; nothing-to-judge, deleted records and no Clef ask nothing; each child-safety case (account label, negated label on another record, report on the account, report on a record, self-label) asks nothing and fetches no image, while another account's or another label's stops nothing; taken-down and deleted accounts are skipped; Clef retries (503 withretry-after, 429), final 400,success: false, giving up, unwrapped results, nobody listening; arrivals end to end, the account asked about once; a failed subject tried again, and dropped after its attempts; bridged labeling by handle and by footer, and by DID document for an account with no handle (one whose document fails once is tried again, one that cannot be read is left alone after its tries, and one that never answers holds up nothing), not for photos or other accounts, and not over a negated label. Unit tests cover thresholds, labels, answer scoring, env parsing and shrinking. Build, clippy, the coverage gate and the rest are left to CI.Shortcomings
nsfwcould send it. The gate stops everything once anyone flags it. Gating harder would mean not scanning photos at all.Index::arrived) is the push sender's: it now tells the classifiers (Index::classify_to, a sender of their own) and then the sender. Each has its own queue, so one that falls behind drops only its own arrivals.report_onceandmay_classifyshould move onto itsfile_reportandchild_safety, and its migration renumbers after V10 (or this one after it).Review
The gate in
index/classify.rs(may_classify) andClassify::scoreinclassify/mod.rsare where to start.🤖 Generated with Claude Code