From ce6599a82fc9f853a7564f580ccdaf3962384230 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sun, 25 Oct 2026 16:15:05 -0400 Subject: [PATCH] fix(index): take accounts let go for room off the leaderboard with the board The denylist deletes the burn of a released account it names and drops a denylisted person as operator of kept trees; a takedown or a dropped label lets go of the kept tree, as it hides a held account's burn. Co-Authored-By: Claude Opus 5.5 Change-Id: Ib7bc96539576ce96f36c7d99c1fcb093ca5098b8 --- crates/vibescrobble-index/src/backfill.rs | 16 ++- crates/vibescrobble-index/src/burn.rs | 112 ++++++++++++++++-- crates/vibescrobble-index/src/intake.rs | 1 + crates/vibescrobble-index/src/moderation.rs | 20 ++++ crates/vibescrobble-index/src/operator.rs | 7 +- crates/vibescrobble-index/src/relay.rs | 1 + crates/vibescrobble-index/src/worker.rs | 1 + .../tests/account_frames.rs | 73 +++++++++++- .../vibescrobble-index/tests/burn_released.rs | 53 ++++++++- docs/state.md | 4 +- 10 files changed, 268 insertions(+), 20 deletions(-) diff --git a/crates/vibescrobble-index/src/backfill.rs b/crates/vibescrobble-index/src/backfill.rs index 6e76d463..a1879f20 100644 --- a/crates/vibescrobble-index/src/backfill.rs +++ b/crates/vibescrobble-index/src/backfill.rs @@ -404,10 +404,16 @@ pub async fn from_relay( .await; let inactive = listed.inactive(); // An account its host took down is off the board, whatever the view - // was holding: one taken back from a saved state included. + // was holding: one taken back from a saved state included. So is the + // burn of one the view let go for room. for did in listed.taken_down().iter().map(Did::as_str) { let mut view = shared.view.lock().await; - if view.knows(did) && !overheard.is_some_and(|overheard| overheard.spoke_for(did, true)) { + let held = view.knows(did); + if overheard.is_some_and(|overheard| overheard.spoke_for(did, held)) { + continue; + } + shared.burn.unrelease(did); + if held { info!(did, "listed as taken down by its host; taken off the board"); let announced = view.forget(did); drop(view); @@ -626,7 +632,11 @@ async fn take( // for it since this listing was read. let mut view = shared.view.lock().await; let held = view.knows(did); - if held && !overheard.is_some_and(|overheard| overheard.spoke_for(did, held)) { + if overheard.is_some_and(|overheard| overheard.spoke_for(did, held)) { + continue; + } + shared.burn.unrelease(did); + if held { info!( did, "listed without the bot self-label; taken off the board" diff --git a/crates/vibescrobble-index/src/burn.rs b/crates/vibescrobble-index/src/burn.rs index 6b9343a8..f50f3c9b 100644 --- a/crates/vibescrobble-index/src/burn.rs +++ b/crates/vibescrobble-index/src/burn.rs @@ -54,7 +54,10 @@ //! its `bot` label, is left out at query time and is back when it is held //! again. One the view let go to keep the dormant accounts within //! `--keep-dormant` is not: the store keeps who it rolled up to as it went -//! ([`Store::release`]), and counts it there until it is held again. +//! ([`Store::release`]), and counts it there until it is held again, or +//! until it is taken off: the denylist deletes its rows, its host's takedown +//! leaves them out, and a person the denylist names is nobody's operator +//! there either. use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::io::{BufRead, Write}; @@ -622,15 +625,40 @@ impl Inner { .is_some_and(|((key, ..), _)| first(key)) } + /// Deletes everything held for `author`. Whether any row or daily was. + /// + /// Reads only `author`'s own rows, since an account the denylist names + /// is forgotten each time it commits. fn forget(&mut self, author: &str) -> bool { - let before = self.windows.len() + self.retired.len(); - self.windows.retain(|(held, _), _| held.as_str() != author); - self.retired.retain(|(held, ..), _| held.as_str() != author); - self.folded_rows - .retain(|(held, _), _| held.as_str() != author); - self.filled.retain(|held| held.as_str() != author); - self.released.retain(|held, _| held.as_str() != author); - before != self.windows.len() + self.retired.len() + let Some(author) = Did::key(author) else { + return false; + }; + let theirs = |rows: &BTreeMap<(Did, String), Window>| -> Vec<(Did, String)> { + rows.range((author.clone(), String::new())..) + .take_while(|((held, _), _)| *held == author) + .map(|(key, _)| key.clone()) + .collect() + }; + let windows = theirs(&self.windows); + let folded = theirs(&self.folded_rows); + let dailies: Vec = self + .retired + .range((author.clone(), String::new(), String::new(), String::new())..) + .take_while(|(key, _)| key.0 == author) + .map(|(key, _)| key.clone()) + .collect(); + for key in &windows { + self.windows.remove(key); + } + for key in &folded { + self.folded_rows.remove(key); + } + for key in &dailies { + self.retired.remove(key); + } + self.filled.remove(&author); + self.released.remove(&author); + !windows.is_empty() || !dailies.is_empty() } fn apply(&mut self, entry: Entry) { @@ -1141,8 +1169,9 @@ impl Store { })); } - /// Lets go of the tree kept for `did`, which the view holds again. - pub fn hold(&self, did: &str) { + /// Lets go of the tree kept for `did`: the view holds it again, or it + /// was taken off the board. Its rows stay, left out unless it is held. + pub fn unrelease(&self, did: &str) { let mut inner = self.lock(); let Some((author, _)) = inner.released.remove_entry(did) else { return; @@ -1150,6 +1179,42 @@ impl Store { inner.write(&Entry::Held(author)); } + /// Every account let go for room whose tree is kept. + pub fn released(&self) -> Vec { + self.lock() + .released + .keys() + .map(|did| did.as_str().to_owned()) + .collect() + } + + /// Takes each person `denied` names off as operator of every tree kept + /// for an account let go for room: each such account rolls up to itself, + /// as an account with no operator does. + pub fn disown(&self, denied: impl Fn(&str) -> bool) { + let mut inner = self.lock(); + let theirs: Vec = inner + .released + .iter() + .filter(|(author, lineage)| { + lineage.operator != author.as_str() && denied(&lineage.operator) + }) + .map(|(author, _)| author.clone()) + .collect(); + for author in theirs { + let Some(lineage) = inner.released.get_mut(&author) else { + continue; + }; + lineage.operator = author.as_str().to_owned(); + let lineage = lineage.clone(); + debug!( + did = author.as_str(), + "burn keeps a tree whose operator is denylisted as nobody's" + ); + inner.write(&Entry::Released(LetGo { author, lineage })); + } + } + /// What an announced event changes. pub fn observe(&self, event: &Event) { match event { @@ -1161,7 +1226,7 @@ impl Store { did, lineage: Some(lineage), } => self.release(did, lineage), - Event::Provisioned(agent) => self.hold(&agent.did), + Event::Provisioned(agent) => self.unrelease(&agent.did), _ => {} } } @@ -1687,6 +1752,29 @@ impl Burn { } } + /// Every account let go for room whose tree is kept. See + /// [`Store::release`]. + pub fn released(&self) -> Vec { + self.store() + .map(|store| store.released()) + .unwrap_or_default() + } + + /// Lets go of the tree kept for `did`, taken off the board by its host. + pub fn unrelease(&self, did: &str) { + if let Some(store) = self.store() { + store.unrelease(did); + } + } + + /// Takes denylisted people off as operators of kept trees. See + /// [`Store::disown`]. + pub fn disown(&self, denied: impl Fn(&str) -> bool) { + if let Some(store) = self.store() { + store.disown(denied); + } + } + async fn ask(&self, path: &str) -> Result, String> { let Burn::Upstream { index, client } = self else { return Ok(None); diff --git a/crates/vibescrobble-index/src/intake.rs b/crates/vibescrobble-index/src/intake.rs index 84ea54cd..e3780759 100644 --- a/crates/vibescrobble-index/src/intake.rs +++ b/crates/vibescrobble-index/src/intake.rs @@ -153,6 +153,7 @@ impl Intake { status = account.status.as_deref(), "an account was taken down by its host; taken off" ); + self.shared.burn.unrelease(&account.did); let announced = self.shared.view.lock().await.forget(&account.did); self.shared.announce(announced).await; } else { diff --git a/crates/vibescrobble-index/src/moderation.rs b/crates/vibescrobble-index/src/moderation.rs index 4dcb24b7..67e28a5d 100644 --- a/crates/vibescrobble-index/src/moderation.rs +++ b/crates/vibescrobble-index/src/moderation.rs @@ -447,6 +447,11 @@ impl Moderation { /// names that is already held is forgotten, everything it said with it. A /// person it names is nobody's operator from now on. /// +/// An account let go for room is not held, but its token burn still counts +/// in its tree. That burn is deleted when the list names the account by its +/// DID or its `did:web` host. One on a server the list names goes the next +/// time it commits. +/// /// Answers how many held accounts it took off. pub async fn deny(shared: &Shared, list: Denylist) -> usize { let mut view = shared.view.lock().await; @@ -454,6 +459,12 @@ pub async fn deny(shared: &Shared, list: Denylist) -> usize { .agents_after(None, |agent| list.denies_held(agent)) .map(|agent| agent.did.clone()) .collect(); + let released: Vec = shared + .burn + .released() + .into_iter() + .filter(|did| list.denies(did, None, None)) + .collect(); // Under the view's lock, so nothing is admitted between the two. shared.moderation.replace(list); let mut announced = Vec::new(); @@ -461,6 +472,15 @@ pub async fn deny(shared: &Shared, list: Denylist) -> usize { shared.burn.forget(did); announced.extend(view.forget(did)); } + for did in &released { + info!(did, "on the operator's denylist; its burn is deleted"); + shared.burn.forget(did); + } + // Before the view disowns them, which takes their handles with them. + shared.burn.disown(|person| { + let handle = view.operator(person).and_then(|op| op.handle.as_deref()); + shared.moderation.denies_person(person, handle) + }); announced.extend(shared.moderation.disown_denied(&mut view)); drop(view); shared.announce(announced).await; diff --git a/crates/vibescrobble-index/src/operator.rs b/crates/vibescrobble-index/src/operator.rs index 3f9b3a5a..15cb0181 100644 --- a/crates/vibescrobble-index/src/operator.rs +++ b/crates/vibescrobble-index/src/operator.rs @@ -225,7 +225,12 @@ pub async fn admit( Some(false) => { let mut view = shared.view.lock().await; let held = view.knows(&agent.did); - if !held || overheard.is_some_and(|overheard| overheard.spoke_for(&agent.did, held)) { + if overheard.is_some_and(|overheard| overheard.spoke_for(&agent.did, held)) { + return Admitted::default(); + } + // Nor is the burn of one the view let go for room. + shared.burn.unrelease(&agent.did); + if !held { debug!( did = agent.did, "no bot self-label on its profile; not drawn" diff --git a/crates/vibescrobble-index/src/relay.rs b/crates/vibescrobble-index/src/relay.rs index f7729665..d56b7e08 100644 --- a/crates/vibescrobble-index/src/relay.rs +++ b/crates/vibescrobble-index/src/relay.rs @@ -520,6 +520,7 @@ pub async fn mint( } if denied { debug!(did, "on the operator's denylist; not drawn"); + shared.burn.forget(did); let events = shared.view.lock().await.forget(did); return Admitted { changed: !events.is_empty(), diff --git a/crates/vibescrobble-index/src/worker.rs b/crates/vibescrobble-index/src/worker.rs index c40ed462..139ddbeb 100644 --- a/crates/vibescrobble-index/src/worker.rs +++ b/crates/vibescrobble-index/src/worker.rs @@ -419,6 +419,7 @@ async fn apply( "an account was taken down by its host; taken off" ); shared.moderation.took_off(&account.did); + shared.burn.unrelease(&account.did); let announced = shared.view.lock().await.forget(&account.did); shared.announce_from(Some(account.seq), announced).await; } else { diff --git a/crates/vibescrobble-index/tests/account_frames.rs b/crates/vibescrobble-index/tests/account_frames.rs index 5f7e4dcf..7d6bd00d 100644 --- a/crates/vibescrobble-index/tests/account_frames.rs +++ b/crates/vibescrobble-index/tests/account_frames.rs @@ -21,12 +21,13 @@ use std::future::Future; use std::sync::{Arc, Mutex}; use serde_json::{json, Value}; +use vibescrobble_index::burn::{self, Board, Burn, Store, Usage, RETENTION_DAYS}; use vibescrobble_index::firehose::{Account, Commit, Identity}; use vibescrobble_index::http::{HttpGet, HttpResponse, TransportError}; use vibescrobble_index::identity::{Identities, Registry}; use vibescrobble_index::intake::Intake; use vibescrobble_index::{ - AgentDid, DidResolver, ServerDirectory, ServerRef, Shared, StaticDirectory, + AgentDid, DidResolver, Scrobble, ServerDirectory, ServerRef, Shared, StaticDirectory, }; const DEV: &str = "http://localhost:3000"; @@ -102,6 +103,11 @@ fn document_url(did: &str) -> String { /// The `--pds` deployment this index binary would have assembled, minus the /// transport: a target for `DEV` and the server DID every agent on it names. async fn intake(fixture: Fixture) -> (Shared, Intake) { + intake_on(fixture, Shared::new()).await +} + +/// [`intake`], into `shared`. +async fn intake_on(fixture: Fixture, shared: Shared) -> (Shared, Intake) { let (targets, refused) = StaticDirectory::parse(DEV) .expect("a valid server list") .targets() @@ -109,7 +115,6 @@ async fn intake(fixture: Fixture) -> (Shared, Intake) { .into_parts(); assert!(refused.is_empty()); let servers = HashMap::from([(DEV.to_owned(), SERVER.to_owned())]); - let shared = Shared::new(); let registry = Registry::new(DidResolver::new(fixture), Identities::new()); let intake = Intake::new(shared.clone(), registry, targets, servers); (shared, intake) @@ -479,3 +484,67 @@ async fn account_identity_and_commit_compose_into_one_agent() { ); assert_eq!(intake.pending(), 0); } + +#[tokio::test] +async fn an_account_let_go_for_room_then_taken_down_leaves_the_leaderboard() { + let fixture = Fixture::default(); + fixture.set( + document_url(KESTREL), + document(KESTREL, "kestrel.agents.localhost"), + ); + let store = Arc::new(Store::in_memory(RETENTION_DAYS)); + let shared = Shared::new().with_burn(Burn::Local(Arc::clone(&store))); + let (shared, mut intake) = intake_on(fixture, shared).await; + intake + .account(&server(), &account(KESTREL, 1, true, None)) + .await; + store.put(&Scrobble { + uri: format!( + "at://{KESTREL}/{}/3lqm00000001", + vibescrobble_index::nsid::SCROBBLE + ), + did: KESTREL.to_owned(), + created_at: "2026-09-08T10:00:02Z".to_owned(), + usage: Some(Usage { + input: 0, + output: 20, + cache_read: 0, + cache_write: 0, + }), + ..Scrobble::default() + }); + let now = time::macros::datetime!(2026-09-09 10:00 UTC); + let ranked = |shared: &Shared| { + let shared = shared.clone(); + let store = Arc::clone(&store); + async move { + let view = shared.view.lock().await; + let board: Board = + serde_json::from_value(json!({ "by": "agent", "window": "all" })).expect("a board"); + burn::leaderboard(&store, &view, &board, now)["rows"] + .as_array() + .expect("rows") + .len() + } + }; + + // Dormant, and let go for room: still counted. + let events = { + let mut view = shared.view.lock().await; + view.set_prune(Some(std::time::Duration::from_secs(3600))); + view.set_keep_dormant(Some(0)); + view.sweep(now) + }; + shared.announce(events).await; + assert!(!shared.view.lock().await.knows(KESTREL)); + assert_eq!(ranked(&shared).await, 1); + + intake + .account(&server(), &account(KESTREL, 2, false, Some("takendown"))) + .await; + assert_eq!( + ranked(&shared).await, + 0, + "off the leaderboard with the board" + ); +} diff --git a/crates/vibescrobble-index/tests/burn_released.rs b/crates/vibescrobble-index/tests/burn_released.rs index 541f82ab..dc424499 100644 --- a/crates/vibescrobble-index/tests/burn_released.rs +++ b/crates/vibescrobble-index/tests/burn_released.rs @@ -1,12 +1,14 @@ //! An account let go to keep the dormant accounts within `--keep-dormant` is //! still counted in its tree's and its operator's token burn, through a -//! restart, while an account the board forgets is still left out. +//! restart, while an account the board forgets is still left out, and one +//! the denylist names is deleted. use std::sync::Arc; use serde_json::{json, Value}; use time::macros::datetime; use vibescrobble_index::burn::{self, Board, Burn, Lineages, Store, Whose, RETENTION_DAYS}; +use vibescrobble_index::moderation::Denylist; use vibescrobble_index::view::{Agent, Event, Human, Kept}; use vibescrobble_index::Shared; @@ -185,3 +187,52 @@ async fn an_account_let_go_for_room_keeps_its_burn_in_its_tree() { assert_eq!(all_time(&again, Whose::Operator(PERSON)).await, (1, 20)); assert_eq!(all_time(&again, Whose::Agent(SESSION)).await, (1, 20)); } + +/// Both accounts with usage, and the session let go for room. +async fn released(name: &str) -> Shared { + let path = scratch(name); + let shared = start(&path, None).await; + { + let mut view = shared.view.lock().await; + view.see_agent(agent(HOST, None)); + view.see_agent(agent(SESSION, Some(HOST))); + } + say(&shared, HOST, 0, 10).await; + say(&shared, SESSION, 1, 20).await; + let events = { + let mut view = shared.view.lock().await; + view.set_prune(Some(std::time::Duration::from_secs(3600))); + view.set_keep_dormant(Some(1)); + view.sweep(NOW) + }; + shared.announce(events).await; + assert!(shared.view.lock().await.agent(SESSION).is_none()); + assert_eq!(agents_ranked(&shared).await, [SESSION, HOST]); + shared +} + +#[tokio::test] +async fn an_account_let_go_for_room_leaves_with_the_denylist() { + let shared = released("denied.json").await; + vibescrobble_index::moderation::deny(&shared, Denylist::parse(SESSION)).await; + + assert_eq!(agents_ranked(&shared).await, [HOST]); + assert_eq!(all_time(&shared, Whose::Operator(PERSON)).await, (1, 10)); + assert_eq!(all_time(&shared, Whose::Agent(HOST)).await, (1, 10)); +} + +#[tokio::test] +async fn a_denylisted_person_is_nobodys_operator_in_a_kept_tree() { + let shared = released("disowned.json").await; + let person = serde_json::from_value(json!({ "did": PERSON, "handle": "quernstone.example" })) + .expect("an operator"); + shared.view.lock().await.see_operator(person); + vibescrobble_index::moderation::deny(&shared, Denylist::parse(PERSON)).await; + + assert_eq!(all_time(&shared, Whose::Operator(PERSON)).await, (0, 0)); + assert_eq!( + agents_ranked(&shared).await, + [SESSION, HOST], + "both accounts are still drawn, unclaimed" + ); +} diff --git a/docs/state.md b/docs/state.md index 9c0377d8..1b56b623 100644 --- a/docs/state.md +++ b/docs/state.md @@ -73,7 +73,9 @@ it, and never lowered. So is an account a read found had written more than its newest 32 while the index was away. A window whose record is gone from its repository is held only here. So is the tree, and the operator, of each counted account the board let go past -`--keep-dormant`: its burn stays in those totals until it is held again. +`--keep-dormant`: its burn stays in those totals until it is held again, the +denylist deletes it, or its host takes it down. A person the denylist names +is dropped as the operator of those trees. `identities.jsonl` keeps every account it has resolved. A `did:web` account that has ended stops resolving, so its line is the only record of who wrote -- knotmirror 0.0.1