//! Rules that hold for any Tangled record carrying a state, whoever wrote it. use anyhow::Result; /// Which of two state records is the newer, ordered the way the indexers /// order them. /// /// The appview's `stateWinner` is `order by created_micros desc, at_uri desc /// limit 1`: the timestamp as an instant, ties broken by the whole at-uri — /// which contains the writer's DID, and so is the half that differs when the /// two accounts Tangled honours both write at the same moment. /// /// **This existed three times among the readers that decide state, and the /// three disagreed**, while two of them carried doc comments asserting they /// agreed. One broke the tie on the raw `createdAt` *string* first, a step /// the appview has no equivalent of, so `+03:00` beat `Z`. Another broke it /// on the record key alone, ignoring the DID. A pull could read `closed` from /// the command that closes it and `merged` from the command that lists it. /// /// Takes the two fields rather than a record type, because its callers hold /// different shapes and the rule is about neither of them. pub(crate) fn newer_state(a: (&str, &str), b: (&str, &str)) -> std::cmp::Ordering { use jacquard::types::string::Datetime; use std::str::FromStr; Datetime::from_str(a.0) .ok() .cmp(&Datetime::from_str(b.0).ok()) .then_with(|| a.1.cmp(b.1)) } /// Whether an account may write a state or status record that Tangled will /// actually honour. /// /// The record goes in the writer's own PDS, so the PDS will accept anything; /// the question is whether the indexers apply it. They disagree slightly, and /// this takes the intersection: /// /// - The Go appview honours the record's author, the target repo's owner, any /// collaborator holding `repo:push`, and Tangled's own DID; anyone else's /// is dropped after an ACL check against the knot. /// - Bobbin has no ACL and filters at query time instead, accepting only the /// author and the repo owner's DID. /// /// So author-or-owner is the set both honour, and it is the set atgc can /// establish without asking a knot. A collaborator's close would work on /// tangled.org and be invisible in Bobbin-backed output, which is worse than /// being told to use the web UI — so that case is refused, and the refusal /// says why. `TODO.md` records what closing the gap would take. /// /// Pulls and issues draw this same line, and drew it in two separate copies /// until this one. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Standing { Author, RepoOwner, Neither, } /// Where `actor_did` stands against a record authored by `author_did` on a /// repo owned by `repo_did`, which is `None` when the repo could not be /// resolved — an unresolved repo settles nothing, so it answers `Neither` /// rather than assuming either way. pub(crate) async fn standing_of( actor_did: &str, author_did: &str, repo_did: Option<&str>, ) -> Result { if actor_did == author_did { return Ok(Standing::Author); } let Some(repo_did) = repo_did else { return Ok(Standing::Neither); }; Ok( match crate::clients::tangled::ownership::owns_repo(actor_did, repo_did).await? { true => Standing::RepoOwner, false => Standing::Neither, }, ) } #[cfg(test)] mod tests { use super::*; use std::cmp::Ordering; #[test] fn an_offset_timestamp_is_compared_as_an_instant() { // 15:00+03:00 *is* 12:00Z. The string comparison this replaced said // the offset one was later. assert_eq!( newer_state( ("2026-01-01T12:00:00Z", "at://a"), ("2026-01-01T15:00:00+03:00", "at://a") ), Ordering::Equal, ); } #[test] fn a_tie_breaks_on_the_whole_at_uri_including_the_did() { let t = "2026-01-01T12:00:00Z"; assert_eq!( newer_state((t, "at://did:plc:aaa/c/k"), (t, "at://did:plc:bbb/c/k")), Ordering::Less, ); } #[test] fn the_later_instant_wins_regardless_of_uri() { assert_eq!( newer_state( ("2026-01-02T00:00:00Z", "at://a"), ("2026-01-01T00:00:00Z", "at://z") ), Ordering::Greater, ); } #[test] fn an_unparseable_timestamp_loses_to_a_real_one() { assert_eq!( newer_state(("not a date", "at://a"), ("2026-01-01T00:00:00Z", "at://a")), Ordering::Less, ); } } /// The account whose repository a record lives in: the authority segment of /// `at:////`. /// /// A record's authority *is* its author — an at-uri names the repository the /// record sits in, and only that account can write there — so this answers /// "whose is it" without touching the record. /// /// `None` for anything that is not an at-uri, which then matches no account /// rather than matching every one. pub(crate) fn authority_of(uri: &str) -> Option<&str> { uri.strip_prefix("at://")?.split('/').next() } /// Whether `uri` names a record in `did`'s repository. /// /// **Split on the delimiter, never `starts_with` the DID.** One copy of this /// tested `rest.starts_with(me)` with no trailing `/`, which makes any /// account whose DID begins with yours read as you. `did:plc` is /// fixed-width so it could not bite there, but a `did:web` is as long as its /// domain: standing as `did:web:example.com`, a record at /// `at://did:web:example.com.evil.net/...` came back as yours — and the one /// caller promotes a record it believes is yours from "state unknown" to /// "open". pub(crate) fn is_authored_by(uri: &str, did: &str) -> bool { authority_of(uri) == Some(did) } #[cfg(test)] mod absence_tests { use super::absence_is_open; #[test] fn an_owner_and_a_whole_walk_settle_it_open() { assert!(absence_is_open(true, true)); } #[test] fn a_truncated_walk_settles_nothing_even_for_the_owner() { // The issue path had exactly this case wrong: it printed `open` off a // walk that had stopped at its page cap, having just warned that the // row would read `?`. assert!(!absence_is_open(true, false)); } #[test] fn a_whole_walk_settles_nothing_for_an_account_that_does_not_write_here() { assert!(!absence_is_open(false, true)); } #[test] fn neither_settles_nothing() { assert!(!absence_is_open(false, false)); } } #[cfg(test)] mod authority_tests { use super::*; #[test] fn the_authority_is_the_first_segment() { assert_eq!( authority_of("at://did:plc:abc/sh.tangled.repo.pull/3k"), Some("did:plc:abc") ); } #[test] fn anything_that_is_not_an_at_uri_has_no_authority() { assert_eq!(authority_of("https://example.com/x"), None); assert_eq!(authority_of(""), None); } #[test] fn an_account_whose_did_merely_begins_with_mine_is_not_me() { // The whole reason this is a function: `starts_with` says yes here. let theirs = "at://did:web:example.com.evil.net/sh.tangled.repo.pull/3k"; assert!(!is_authored_by(theirs, "did:web:example.com")); assert!(is_authored_by( "at://did:web:example.com/sh.tangled.repo.pull/3k", "did:web:example.com" )); } #[test] fn a_bare_authority_with_no_collection_still_reads() { assert_eq!(authority_of("at://did:plc:abc"), Some("did:plc:abc")); } } /// Whether a *missing* state record may be read as "open". /// /// An issue or a pull with no state record is open — that is how Tangled /// spells a new one — but only to a reader who can say it has seen every /// record that could exist. Two things have to hold, and neither is enough /// alone: /// /// - the account whose absence is being read from writes state records for /// this repo at all (`owner_writes_here`), and /// - the walk that found none of them reached the end of the collection /// rather than stopping at a page cap (`walk_complete`). /// /// **The second condition existed on the pull path and was missing on the /// issue path**, where the walk computed it, printed a warning promising the /// row would read `?` — and then read the absence as `open` anyway. A repo /// owner with more state records than the cap allows saw an issue they had /// closed listed as open, off evidence the code had already gathered and /// dropped. pub(crate) fn absence_is_open(owner_writes_here: bool, walk_complete: bool) -> bool { owner_writes_here && walk_complete }