fix(nip-ar): do not send a sync heads of a type it never matched

A sync registers for change signals by channel, so a revision of any
type in a named channel woke every sync there, and each one sent the
head. A Work board that syncs its three record types got every change
three times. A head that fails the filter on an immutable tag (type or
d) was never in scope, so it is now skipped. Heads that stop matching a
mutable tag are still sent, as rule 3 requires.

Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-10-21 21:06:10 -04:00
parent bd2cdf0923
commit 761f3e75db
3 changed files with 88 additions and 2 deletions

View File

@@ -18,6 +18,28 @@ pub struct ArtifactSyncFilter {
pub tags: Vec<(String, Vec<String>)>,
}
/// Tags NIP-AR fixes at creation. A head that fails a predicate on one of
/// them was never in scope and never will be.
const IMMUTABLE_TAGS: [&str; 2] = ["d", "type"];
impl ArtifactSyncFilter {
/// Whether `head` fails a predicate on an immutable tag, so no change to
/// it can be in scope (NIP-AR sync rule 3). Mutable tags are left to the
/// client's match rule: a head that stopped matching must still be sent.
pub fn never_in_scope(&self, head: &nostr::Event) -> bool {
self.tags
.iter()
.filter(|(name, _)| IMMUTABLE_TAGS.contains(&name.as_str()))
.any(|(name, values)| {
let first = head.tags.iter().find_map(|tag| match tag.as_slice() {
[tag_name, value, ..] if tag_name == name => Some(value),
_ => None,
});
!first.is_some_and(|value| values.contains(value))
})
}
}
/// Whether a raw `REQ` filter is a sync: the current-state view. Other
/// artifact views have no live form and stay HTTP-only.
pub fn is_sync_filter(value: &serde_json::Value) -> bool {
@@ -77,4 +99,35 @@ mod tests {
assert!(parse_sync(&invalid).is_err(), "{invalid}");
}
}
#[test]
fn never_in_scope_checks_only_immutable_tags() {
let h = Uuid::new_v4().to_string();
let filter = parse_sync(
&json!({"artifact":"current","#h":[h],"#type":["work.set"],"#status":["open"]}),
)
.unwrap();
let keys = nostr::Keys::generate();
let head = |tags: &[[&str; 2]]| {
nostr::EventBuilder::new(nostr::Kind::Custom(45010), "")
.tags(tags.iter().map(|t| nostr::Tag::parse(t.to_vec()).unwrap()))
.sign_with_keys(&keys)
.unwrap()
};
// Another type: never in scope, whatever its other tags.
assert!(filter.never_in_scope(&head(&[
["h", &h],
["type", "work.goals"],
["status", "open"]
])));
assert!(filter.never_in_scope(&head(&[["h", &h]])));
// Same type but a mutable tag no longer matches: still sent, so the
// client can drop it.
assert!(!filter.never_in_scope(&head(&[
["h", &h],
["type", "work.set"],
["status", "done"]
])));
assert!(!filter.never_in_scope(&head(&[["h", &h], ["type", "work.set"]])));
}
}

View File

@@ -547,6 +547,14 @@ async fn run_live(
return;
}
};
// A head of another type (or `d`) was never in scope; the change
// signal is per channel, so it reaches every sync there.
if head
.as_ref()
.is_some_and(|head| filter.never_in_scope(&head.event))
{
continue;
}
// A head the reader cannot read leaves only the source removal.
let Some(event) = head.map(|head| head.event).or(removal) else {
continue;

View File

@@ -65,7 +65,7 @@ async fn ingest(f: &Fixture, keys: &Keys, event: Event) {
assert!(result.accepted, "kind:{kind} refused: {}", result.message);
}
/// Accept one artifact revision and return it.
/// Accept one `buzz.task` revision and return it.
async fn revision(
f: &Fixture,
keys: &Keys,
@@ -73,12 +73,25 @@ async fn revision(
d: Uuid,
op: &str,
prev: Option<&Event>,
) -> Event {
typed_revision(f, keys, home, d, "buzz.task", op, prev).await
}
/// Accept one artifact revision of type `ty` and return it.
async fn typed_revision(
f: &Fixture,
keys: &Keys,
home: Uuid,
d: Uuid,
ty: &str,
op: &str,
prev: Option<&Event>,
) -> Event {
let mut tags = vec![
vec!["ar".to_owned(), "1".into()],
vec!["d".into(), d.to_string()],
vec!["h".into(), home.to_string()],
vec!["type".into(), "buzz.task".into()],
vec!["type".into(), ty.into()],
vec!["op".into(), op.into()],
];
if op != "delete" {
@@ -219,6 +232,18 @@ async fn sync_sends_one_cut_then_current_heads() {
(message[0].as_str(), message[2]["id"].as_str()),
(Some("EVENT"), Some(deleted.id.to_hex().as_str()))
);
// An artifact of another type in the same channel is never in scope.
typed_revision(
&f,
&owner,
home,
Uuid::new_v4(),
"buzz.note",
"create",
None,
)
.await;
no_frame(&mut rx).await;
// An ordinary chat message in the channel is not a change.
let chat = EventBuilder::new(Kind::Custom(9), "hi")
.tags([Tag::parse(["h", &home.to_string()]).unwrap()])