Monorepo for Tangled
tangled.org
1use alloc::collections::BTreeMap;
2use bobbin_types::com_atproto::repo::strong_ref::StrongRef;
3use bobbin_types::edges::{ExtractError, Record};
4use bobbin_types::legacy::{
5 LEGACY_COMMENT_SENTINEL_CID, LegacyCollaborator, LegacyIssue, LegacyIssueComment,
6 LegacyKnotMember, LegacyPublicKey, LegacyPull, LegacyPullComment, LegacyRecord,
7 LegacyRefUpdate, LegacyRepo, LegacySource, LegacyStar, LegacyTarget,
8};
9use bobbin_types::sh_tangled::feed::comment::Comment as FeedComment;
10use bobbin_types::sh_tangled::feed::star::{Repo as StarRepo, Star, StarString, StarSubject};
11use bobbin_types::sh_tangled::git::ref_update::RefUpdate;
12use bobbin_types::sh_tangled::knot::member::Member as KnotMember;
13use bobbin_types::sh_tangled::markup::markdown::Markdown;
14use bobbin_types::sh_tangled::public_key::PublicKey;
15use bobbin_types::sh_tangled::repo::Repo;
16use bobbin_types::sh_tangled::repo::collaborator::Collaborator;
17use bobbin_types::sh_tangled::repo::issue::Issue;
18use bobbin_types::sh_tangled::repo::pull::{Pull, Round, Source, Target};
19use jacquard_common::deps::smol_str::SmolStr;
20use jacquard_common::types::did::Did;
21use jacquard_common::types::nsid::Nsid;
22use jacquard_common::types::string::{AtUri, AtprotoStr, Cid};
23use jacquard_common::types::value::{Array, Data};
24use jacquard_common::{BosStr, DefaultStr};
25
26use crate::normalize::{is_repo_at_uri, resolve_repo_uri};
27use crate::{RepoIdResolver, Resolution};
28use jacquard_common::IntoStatic;
29use jacquard_common::types::ident::AtIdentifier;
30use jacquard_common::types::recordkey::Rkey;
31
32#[derive(Debug)]
33pub enum DecodedRecord {
34 Canon(Record),
35 Legacy(LegacyRecord),
36}
37
38impl DecodedRecord {
39 pub fn try_decode<S: BosStr + AsRef<str>>(
40 nsid: &Nsid<S>,
41 bytes: &[u8],
42 ) -> Result<Self, ExtractError> {
43 match Record::from_json_bytes(nsid, bytes) {
44 Ok(record) => Ok(Self::Canon(record)),
45 Err(canon_err) => {
46 let normalized = normalize_record_fields(bytes);
47 let working: &[u8] = normalized.as_deref().unwrap_or(bytes);
48 if normalized.is_some()
49 && let Ok(record) = Record::from_json_bytes(nsid, working)
50 {
51 return Ok(Self::Canon(record));
52 }
53 if let Some(scrubbed) = scrub_record_bytes(nsid, working) {
54 if let Ok(record) = Record::from_json_bytes(nsid, &scrubbed) {
55 return Ok(Self::Canon(record));
56 }
57 if let Ok(legacy) = LegacyRecord::from_json_bytes(nsid, &scrubbed) {
58 return Ok(Self::Legacy(legacy));
59 }
60 }
61 match LegacyRecord::from_json_bytes(nsid, working) {
62 Ok(legacy) => Ok(Self::Legacy(legacy)),
63 Err(_) => Err(canon_err),
64 }
65 }
66 }
67 }
68}
69
70pub fn normalize_record_fields(bytes: &[u8]) -> Option<alloc::vec::Vec<u8>> {
71 let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
72 let reserialized = serde_json::to_vec(&value).ok()?;
73 (reserialized.as_slice() != bytes).then_some(reserialized)
74}
75
76pub fn synthesize_created_at(bytes: &[u8], fallback_rfc3339: &str) -> Option<alloc::vec::Vec<u8>> {
77 let mut value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
78 let obj = value.as_object_mut()?;
79 let needs_fill = match obj.get("createdAt") {
80 None => true,
81 Some(serde_json::Value::String(s)) if s.is_empty() => true,
82 _ => false,
83 };
84 if !needs_fill {
85 return None;
86 }
87 obj.insert(
88 "createdAt".to_owned(),
89 serde_json::Value::String(fallback_rfc3339.to_owned()),
90 );
91 serde_json::to_vec(&value).ok()
92}
93
94#[derive(Clone, Copy, Debug)]
95enum FieldRule {
96 DropIfEmptyString,
97 NullToEmptyArray,
98}
99
100fn scrub_rules(nsid: &str) -> &'static [(&'static str, FieldRule)] {
101 match nsid {
102 "sh.tangled.actor.profile" => &[("preferredHandle", FieldRule::DropIfEmptyString)],
103 "sh.tangled.label.op" => &[
104 ("add", FieldRule::NullToEmptyArray),
105 ("delete", FieldRule::NullToEmptyArray),
106 ],
107 "sh.tangled.repo.pull" => &[("rounds", FieldRule::NullToEmptyArray)],
108 _ => &[],
109 }
110}
111
112pub fn scrub_record_bytes<S: BosStr + AsRef<str>>(
113 nsid: &Nsid<S>,
114 bytes: &[u8],
115) -> Option<alloc::vec::Vec<u8>> {
116 let rules = scrub_rules(nsid.as_ref());
117 if rules.is_empty() {
118 return None;
119 }
120 let value: serde_json::Value = serde_json::from_slice(bytes).ok()?;
121 let mut obj = value.as_object()?.clone();
122 let touched: alloc::vec::Vec<(&str, FieldRule)> = rules
123 .iter()
124 .filter_map(|(field, rule)| match (rule, obj.get(*field)) {
125 (FieldRule::DropIfEmptyString, Some(serde_json::Value::String(s))) if s.is_empty() => {
126 Some((*field, *rule))
127 }
128 (FieldRule::NullToEmptyArray, Some(serde_json::Value::Null)) => Some((*field, *rule)),
129 _ => None,
130 })
131 .collect();
132 if touched.is_empty() {
133 return None;
134 }
135 touched.iter().for_each(|(field, rule)| match rule {
136 FieldRule::DropIfEmptyString => {
137 obj.remove(*field);
138 }
139 FieldRule::NullToEmptyArray => {
140 obj.insert(
141 (*field).to_owned(),
142 serde_json::Value::Array(alloc::vec::Vec::new()),
143 );
144 }
145 });
146 tracing::debug!(nsid = %nsid.as_ref(), ?touched, "scrubbing fields before record retry");
147 serde_json::to_vec(&serde_json::Value::Object(obj)).ok()
148}
149
150async fn upgrade_repo_did(
151 resolver: &RepoIdResolver,
152 at_uri: Option<AtUri<DefaultStr>>,
153 explicit_did: Option<Did<DefaultStr>>,
154) -> Option<Did<DefaultStr>> {
155 if let Some(d) = explicit_did {
156 return Some(d);
157 }
158 let uri = at_uri?;
159 resolve_repo_uri(resolver, &uri).await
160}
161
162pub async fn upgrade_wire_bytes<S: BosStr + AsRef<str>>(
163 nsid: &Nsid<S>,
164 bytes: &[u8],
165 resolver: &RepoIdResolver,
166) -> Result<alloc::vec::Vec<u8>, ExtractError> {
167 let legacy = LegacyRecord::from_json_bytes(nsid, bytes)?;
168 let canon = upgrade(legacy, resolver)
169 .await
170 .ok_or_else(|| upgrade_failed(nsid))?;
171 serialize_canon_variant(&canon).map_err(ExtractError::DecodeJson)
172}
173
174fn upgrade_failed<S: BosStr + AsRef<str>>(nsid: &Nsid<S>) -> ExtractError {
175 ExtractError::UnknownCollection(alloc::format!("{}: legacy upgrade failed", nsid.as_ref()))
176}
177
178pub async fn decode_canon_or_upgrade<S: BosStr + AsRef<str>>(
179 nsid: &Nsid<S>,
180 bytes: &[u8],
181 resolver: &RepoIdResolver,
182) -> Result<Record, ExtractError> {
183 match DecodedRecord::try_decode(nsid, bytes)? {
184 DecodedRecord::Canon(r) => Ok(r),
185 DecodedRecord::Legacy(legacy) => upgrade(legacy, resolver)
186 .await
187 .ok_or_else(|| upgrade_failed(nsid)),
188 }
189}
190
191pub async fn decode_canon_or_upgrade_bytes<'a, S: BosStr + AsRef<str>>(
192 nsid: &Nsid<S>,
193 bytes: &'a [u8],
194 resolver: &RepoIdResolver,
195) -> Result<(Record, alloc::borrow::Cow<'a, [u8]>), ExtractError> {
196 let decoded = DecodedRecord::try_decode(nsid, bytes)?;
197 match decoded {
198 DecodedRecord::Canon(r) => Ok((r, alloc::borrow::Cow::Borrowed(bytes))),
199 DecodedRecord::Legacy(legacy) => {
200 let canon = upgrade(legacy, resolver)
201 .await
202 .ok_or_else(|| upgrade_failed(nsid))?;
203 let canon_bytes = serialize_canon_variant(&canon).map_err(ExtractError::DecodeJson)?;
204 Ok((canon, alloc::borrow::Cow::Owned(canon_bytes)))
205 }
206 }
207}
208
209fn serialize_canon_variant(record: &Record) -> Result<alloc::vec::Vec<u8>, serde_json::Error> {
210 match record {
211 Record::FeedComment(r) => serde_json::to_vec(r),
212 Record::Issue(r) => serde_json::to_vec(r),
213 Record::Pull(r) => serde_json::to_vec(r),
214 Record::Collaborator(r) => serde_json::to_vec(r),
215 Record::RefUpdate(r) => serde_json::to_vec(r),
216 Record::Star(r) => serde_json::to_vec(r),
217 Record::PublicKey(r) => serde_json::to_vec(r),
218 Record::Repo(r) => serde_json::to_vec(r),
219 Record::KnotMember(r) => serde_json::to_vec(r),
220 _ => unreachable!(
221 "upgrade only produces FeedComment/Issue/Pull/Collaborator/RefUpdate/Star/PublicKey/Repo/KnotMember"
222 ),
223 }
224}
225
226pub async fn upgrade(legacy: LegacyRecord, resolver: &RepoIdResolver) -> Option<Record> {
227 match legacy {
228 LegacyRecord::Issue(l) => upgrade_issue(l, resolver).await.map(Record::Issue),
229 LegacyRecord::IssueComment(l) => Some(Record::FeedComment(upgrade_issue_comment(l))),
230 LegacyRecord::Pull(l) => upgrade_pull(l, resolver).await.map(Record::Pull),
231 LegacyRecord::PullComment(l) => Some(Record::FeedComment(upgrade_pull_comment(l))),
232 LegacyRecord::Collaborator(l) => upgrade_collaborator(l, resolver)
233 .await
234 .map(Record::Collaborator),
235 LegacyRecord::RefUpdate(l) => Some(Record::RefUpdate(upgrade_ref_update(l))),
236 LegacyRecord::Star(l) => upgrade_star(l, resolver).await.map(Record::Star),
237 LegacyRecord::PublicKey(l) => Some(Record::PublicKey(upgrade_public_key(l))),
238 LegacyRecord::Repo(l) => Some(Record::Repo(upgrade_repo(l))),
239 LegacyRecord::KnotMember(l) => Some(Record::KnotMember(upgrade_knot_member(l))),
240 }
241}
242
243fn sentinel_strong_ref(uri: AtUri<DefaultStr>) -> StrongRef<DefaultStr> {
244 let cid = Cid::<DefaultStr>::new_owned(LEGACY_COMMENT_SENTINEL_CID.as_bytes())
245 .expect("LEGACY_COMMENT_SENTINEL_CID is a valid CID literal");
246 StrongRef {
247 uri,
248 cid,
249 extra_data: None,
250 }
251}
252
253fn legacy_body_markdown(text: DefaultStr) -> Markdown<DefaultStr> {
254 Markdown {
255 blobs: None,
256 original: None,
257 text,
258 extra_data: None,
259 }
260}
261
262fn upgrade_issue_comment(l: LegacyIssueComment<DefaultStr>) -> FeedComment<DefaultStr> {
263 FeedComment {
264 body: legacy_body_markdown(l.body),
265 created_at: l.created_at,
266 pull_round_idx: None,
267 reply_to: l.reply_to.map(sentinel_strong_ref),
268 subject: sentinel_strong_ref(l.issue),
269 extra_data: legacy_comment_extras(l.extra_data, l.mentions, l.references),
270 }
271}
272
273fn upgrade_pull_comment(l: LegacyPullComment<DefaultStr>) -> FeedComment<DefaultStr> {
274 FeedComment {
275 body: legacy_body_markdown(l.body),
276 created_at: l.created_at,
277 pull_round_idx: None,
278 reply_to: None,
279 subject: sentinel_strong_ref(l.pull),
280 extra_data: legacy_comment_extras(l.extra_data, l.mentions, l.references),
281 }
282}
283
284fn legacy_comment_extras<S: BosStr>(
285 base: Option<BTreeMap<SmolStr, Data<S>>>,
286 mentions: Option<Vec<Did<S>>>,
287 references: Option<Vec<AtUri<S>>>,
288) -> Option<BTreeMap<SmolStr, Data<S>>> {
289 let mention_entry = mentions.filter(|v| !v.is_empty()).map(|items| {
290 let arr = items
291 .into_iter()
292 .map(|d| Data::String(AtprotoStr::Did(d)))
293 .collect();
294 (SmolStr::new_static("mentions"), Data::Array(Array(arr)))
295 });
296 let reference_entry = references.filter(|v| !v.is_empty()).map(|items| {
297 let arr = items
298 .into_iter()
299 .map(|u| Data::String(AtprotoStr::AtUri(u)))
300 .collect();
301 (SmolStr::new_static("references"), Data::Array(Array(arr)))
302 });
303 let combined: BTreeMap<SmolStr, Data<S>> = base
304 .into_iter()
305 .flatten()
306 .chain(mention_entry)
307 .chain(reference_entry)
308 .collect();
309 if combined.is_empty() {
310 None
311 } else {
312 Some(combined)
313 }
314}
315
316async fn upgrade_issue(
317 l: LegacyIssue<DefaultStr>,
318 resolver: &RepoIdResolver,
319) -> Option<Issue<DefaultStr>> {
320 let repo = upgrade_repo_did(resolver, l.repo, l.repo_did).await?;
321 Some(Issue {
322 created_at: l.created_at,
323 body: l.body,
324 mentions: l.mentions,
325 references: l.references,
326 repo,
327 title: l.title,
328 extra_data: l.extra_data,
329 })
330}
331
332async fn upgrade_target(
333 l: LegacyTarget<DefaultStr>,
334 resolver: &RepoIdResolver,
335) -> Option<Target<DefaultStr>> {
336 let repo = upgrade_repo_did(resolver, l.repo, l.repo_did).await?;
337 Some(Target {
338 branch: l.branch,
339 repo,
340 extra_data: None,
341 })
342}
343
344async fn upgrade_source(
345 l: LegacySource<DefaultStr>,
346 resolver: &RepoIdResolver,
347) -> Source<DefaultStr> {
348 let repo = upgrade_repo_did(resolver, l.repo, l.repo_did).await;
349 Source {
350 branch: l.branch,
351 repo,
352 extra_data: None,
353 }
354}
355
356async fn upgrade_pull(
357 l: LegacyPull<DefaultStr>,
358 resolver: &RepoIdResolver,
359) -> Option<Pull<DefaultStr>> {
360 let target = upgrade_target(l.target, resolver).await?;
361 let source = match l.source {
362 Some(s) => Some(upgrade_source(s, resolver).await),
363 None => None,
364 };
365 let rounds = if l.rounds.is_empty() {
366 l.patch_blob
367 .map(|patch_blob| {
368 alloc::vec![Round {
369 created_at: l.created_at.clone(),
370 patch_blob,
371 extra_data: None,
372 }]
373 })
374 .unwrap_or_default()
375 } else {
376 l.rounds
377 };
378 Some(Pull {
379 created_at: l.created_at,
380 body: l.body,
381 dependent_on: l.dependent_on,
382 mentions: l.mentions,
383 references: l.references,
384 rounds,
385 source,
386 target,
387 title: l.title,
388 extra_data: l.extra_data,
389 })
390}
391
392fn upgrade_public_key(l: LegacyPublicKey<DefaultStr>) -> PublicKey<DefaultStr> {
393 PublicKey {
394 created_at: l.created,
395 key: l.key,
396 name: l.name,
397 extra_data: l.extra_data,
398 }
399}
400
401fn upgrade_repo(l: LegacyRepo<DefaultStr>) -> Repo<DefaultStr> {
402 let _ = l.owner;
403 Repo {
404 created_at: l.added_at,
405 description: l.description,
406 knot: l.knot,
407 labels: None,
408 name: l.name,
409 repo_did: None,
410 source: None,
411 spindle: None,
412 topics: None,
413 website: None,
414 extra_data: l.extra_data,
415 }
416}
417
418fn upgrade_knot_member(l: LegacyKnotMember<DefaultStr>) -> KnotMember<DefaultStr> {
419 KnotMember {
420 created_at: l.added_at,
421 domain: l.domain,
422 subject: l.member,
423 extra_data: l.extra_data,
424 }
425}
426
427async fn upgrade_collaborator(
428 l: LegacyCollaborator<DefaultStr>,
429 resolver: &RepoIdResolver,
430) -> Option<Collaborator<DefaultStr>> {
431 let repo = upgrade_repo_did(resolver, l.repo, l.repo_did).await?;
432 Some(Collaborator {
433 created_at: l.created_at,
434 repo,
435 subject: l.subject,
436 extra_data: l.extra_data,
437 })
438}
439
440fn upgrade_ref_update(l: LegacyRefUpdate<DefaultStr>) -> RefUpdate<DefaultStr> {
441 RefUpdate {
442 committer_did: l.committer_did,
443 meta: l.meta,
444 new_sha: l.new_sha,
445 old_sha: l.old_sha,
446 owner_did: l.owner_did,
447 r#ref: l.r#ref,
448 repo: l.repo_did,
449 extra_data: l.extra_data,
450 }
451}
452
453async fn upgrade_star(
454 l: LegacyStar<DefaultStr>,
455 resolver: &RepoIdResolver,
456) -> Option<Star<DefaultStr>> {
457 let subject = if let Some(did) = l.subject_did {
458 StarSubject::Repo(alloc::boxed::Box::new(StarRepo {
459 did,
460 extra_data: None,
461 }))
462 } else {
463 let uri = l.subject?;
464 let resolved = if is_repo_at_uri(&uri) {
465 cached_repo_did(resolver, &uri).await
466 } else {
467 None
468 };
469 match resolved {
470 Some(did) => StarSubject::Repo(alloc::boxed::Box::new(StarRepo {
471 did,
472 extra_data: None,
473 })),
474 None => StarSubject::String(alloc::boxed::Box::new(StarString {
475 uri,
476 extra_data: None,
477 })),
478 }
479 };
480 Some(Star {
481 created_at: l.created_at,
482 subject,
483 extra_data: l.extra_data,
484 })
485}
486
487async fn cached_repo_did(
488 resolver: &RepoIdResolver,
489 uri: &jacquard_common::types::string::AtUri<DefaultStr>,
490) -> Option<Did<DefaultStr>> {
491 let owner = match uri.authority() {
492 AtIdentifier::Did(d) => d.clone().into_static(),
493 AtIdentifier::Handle(_) => return None,
494 };
495 let rkey: Rkey<DefaultStr> = uri.rkey()?.clone().into_static();
496 match resolver.cached_resolution(&owner, &rkey).await? {
497 Resolution::Mapped(did) => Some(did),
498 Resolution::NoRepoDid | Resolution::Unresolvable => None,
499 }
500}
501
502extern crate alloc;
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::RepoIdResolver;
508 use bobbin_runtime::RuntimeHasher;
509 use bobbin_types::edges::Record;
510 use jacquard_common::DefaultStr;
511 use jacquard_common::types::did::Did;
512 use jacquard_common::types::recordkey::Rkey;
513
514 fn did(s: &str) -> Did<DefaultStr> {
515 Did::new_owned(s).unwrap()
516 }
517
518 fn rkey(s: &str) -> Rkey<DefaultStr> {
519 Rkey::new_owned(s).unwrap()
520 }
521
522 fn nsid(s: &'static str) -> Nsid<DefaultStr> {
523 Nsid::new_static(s).unwrap()
524 }
525
526 #[test]
527 fn legacy_decode_routes_through_try_decode_for_known_nsids() {
528 let json = br#"{"$type":"sh.tangled.repo.issue","repoDid":"did:plc:squid","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
529 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.issue"), json)
530 .expect("legacy issue must decode");
531 assert!(matches!(
532 decoded,
533 DecodedRecord::Legacy(LegacyRecord::Issue(_))
534 ));
535 }
536
537 #[test]
538 fn canon_decode_wins_when_wire_matches_new_shape() {
539 let json = br#"{"$type":"sh.tangled.repo.issue","repo":"did:plc:squid","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
540 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.issue"), json)
541 .expect("canon issue must decode");
542 match decoded {
543 DecodedRecord::Canon(Record::Issue(i)) => {
544 assert_eq!(i.repo.as_ref(), "did:plc:squid")
545 }
546 other => panic!("expected canon issue, got {other:?}"),
547 }
548 }
549
550 #[test]
551 fn scrub_returns_none_for_unknown_nsid() {
552 let json = br#"{"$type":"sh.tangled.repo.issue","preferredHandle":""}"#;
553 assert!(scrub_record_bytes(&nsid("sh.tangled.repo.issue"), json).is_none());
554 }
555
556 #[test]
557 fn scrub_returns_none_when_target_field_is_non_empty() {
558 let json =
559 br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":"nel.pet"}"#;
560 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json).is_none());
561 }
562
563 #[test]
564 fn scrub_returns_none_when_target_field_is_absent() {
565 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true}"#;
566 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json).is_none());
567 }
568
569 #[test]
570 fn scrub_returns_none_for_non_string_value() {
571 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":42}"#;
572 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json).is_none());
573 }
574
575 #[test]
576 fn scrub_returns_none_for_non_object_json() {
577 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"[]").is_none());
578 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"null").is_none());
579 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"123").is_none());
580 }
581
582 #[test]
583 fn scrub_returns_none_for_invalid_json() {
584 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"{not json").is_none());
585 }
586
587 #[test]
588 fn scrub_drops_empty_preferred_handle_and_preserves_other_fields() {
589 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":"","description":"hi"}"#;
590 let scrubbed = scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json)
591 .expect("empty preferredHandle must trigger scrub");
592 let value: serde_json::Value = serde_json::from_slice(&scrubbed).expect("valid json");
593 let obj = value.as_object().expect("object");
594 assert!(!obj.contains_key("preferredHandle"));
595 assert_eq!(obj.get("bluesky"), Some(&serde_json::json!(true)));
596 assert_eq!(obj.get("description"), Some(&serde_json::json!("hi")));
597 assert_eq!(
598 obj.get("$type"),
599 Some(&serde_json::json!("sh.tangled.actor.profile"))
600 );
601 }
602
603 #[test]
604 fn scrub_replaces_null_arrays_with_empty_for_label_op() {
605 let json = br#"{"$type":"sh.tangled.label.op","add":[{"key":"at://did:plc:limpet/sh.tangled.label.definition/k","value":"v"}],"delete":null,"performedAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:limpet/sh.tangled.repo.issue/3aaa"}"#;
606 let scrubbed = scrub_record_bytes(&nsid("sh.tangled.label.op"), json)
607 .expect("null delete must trigger scrub");
608 let value: serde_json::Value = serde_json::from_slice(&scrubbed).expect("valid json");
609 let obj = value.as_object().expect("object");
610 assert_eq!(obj.get("delete"), Some(&serde_json::json!([])));
611 assert!(obj.get("add").is_some_and(|v| v.is_array()));
612 }
613
614 #[test]
615 fn scrub_passes_through_when_label_op_arrays_are_non_null() {
616 let json = br#"{"$type":"sh.tangled.label.op","add":[],"delete":[],"performedAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:limpet/sh.tangled.repo.issue/3aaa"}"#;
617 assert!(scrub_record_bytes(&nsid("sh.tangled.label.op"), json).is_none());
618 }
619
620 #[test]
621 fn try_decode_recovers_label_op_with_null_delete() {
622 let json = br#"{"$type":"sh.tangled.label.op","add":[{"key":"at://did:plc:limpet/sh.tangled.label.definition/k","value":"v"}],"delete":null,"performedAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:limpet/sh.tangled.repo.issue/3aaa"}"#;
623 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.label.op"), json)
624 .expect("label.op with null delete must scrub-recover");
625 assert!(matches!(decoded, DecodedRecord::Canon(Record::LabelOp(_))));
626 }
627
628 #[test]
629 fn try_decode_recovers_profile_with_empty_preferred_handle() {
630 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":"","description":"hi"}"#;
631 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.actor.profile"), json)
632 .expect("profile with empty preferredHandle must scrub-recover");
633 match decoded {
634 DecodedRecord::Canon(Record::Profile(p)) => {
635 assert!(p.preferred_handle.is_none());
636 assert_eq!(p.description.as_deref(), Some("hi"));
637 }
638 other => panic!("expected canon profile, got {other:?}"),
639 }
640 }
641
642 #[test]
643 fn legacy_decode_passes_through_for_unaffected_nsids() {
644 let json = br#"{"$type":"sh.tangled.graph.follow","subject":"did:plc:bailey","createdAt":"2026-05-01T00:00:00Z"}"#;
645 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.graph.follow"), json)
646 .expect("follow has no legacy form, must decode canon");
647 assert!(matches!(decoded, DecodedRecord::Canon(Record::Follow(_))));
648 }
649
650 #[test]
651 fn normalize_returns_none_for_invalid_json() {
652 assert!(normalize_record_fields(b"{not json").is_none());
653 }
654
655 #[test]
656 fn normalize_drops_repeated_dollar_type_key() {
657 let json =
658 br#"{"$type":"sh.tangled.repo.pull","title":"t","$type":"sh.tangled.repo.pull"}"#;
659 let normalized =
660 normalize_record_fields(json).expect("duplicate key must produce normalized output");
661 let value: serde_json::Value = serde_json::from_slice(&normalized).expect("valid json");
662 let obj = value.as_object().expect("object");
663 assert_eq!(obj.len(), 2);
664 assert_eq!(
665 obj.get("$type"),
666 Some(&serde_json::json!("sh.tangled.repo.pull"))
667 );
668 assert_eq!(obj.get("title"), Some(&serde_json::json!("t")));
669 }
670
671 #[test]
672 fn normalize_keeps_last_value_for_repeated_keys() {
673 let json = br#"{"$type":"sh.tangled.repo.issue","$type":"sh.tangled.repo.pull"}"#;
674 let normalized =
675 normalize_record_fields(json).expect("duplicate key must produce normalized output");
676 let value: serde_json::Value = serde_json::from_slice(&normalized).expect("valid json");
677 assert_eq!(
678 value.get("$type"),
679 Some(&serde_json::json!("sh.tangled.repo.pull")),
680 );
681 }
682
683 #[test]
684 fn synthesize_fills_empty_created_at_with_fallback() {
685 let json = br#"{"$type":"sh.tangled.repo.issue","title":"meow","createdAt":""}"#;
686 let patched = synthesize_created_at(json, "2026-05-01T00:00:00.000000Z")
687 .expect("empty createdAt must be filled");
688 let value: serde_json::Value = serde_json::from_slice(&patched).expect("valid json");
689 assert_eq!(
690 value.get("createdAt"),
691 Some(&serde_json::json!("2026-05-01T00:00:00.000000Z")),
692 );
693 }
694
695 #[test]
696 fn synthesize_fills_missing_created_at_with_fallback() {
697 let json = br#"{"$type":"sh.tangled.repo.issue","title":"meow"}"#;
698 let patched = synthesize_created_at(json, "2026-05-01T00:00:00.000000Z")
699 .expect("missing createdAt must be filled");
700 let value: serde_json::Value = serde_json::from_slice(&patched).expect("valid json");
701 assert_eq!(
702 value.get("createdAt"),
703 Some(&serde_json::json!("2026-05-01T00:00:00.000000Z")),
704 );
705 }
706
707 #[test]
708 fn synthesize_returns_none_when_created_at_already_set() {
709 let json = br#"{"$type":"sh.tangled.repo.issue","createdAt":"2026-05-01T00:00:00Z"}"#;
710 assert!(synthesize_created_at(json, "2026-04-01T00:00:00Z").is_none());
711 }
712
713 #[test]
714 fn synthesize_returns_none_for_non_string_created_at() {
715 let json = br#"{"$type":"sh.tangled.repo.issue","createdAt":null}"#;
716 assert!(synthesize_created_at(json, "2026-04-01T00:00:00Z").is_none());
717 }
718
719 #[tokio::test]
720 async fn try_decode_recovers_legacy_issue_after_synthesized_created_at() {
721 let json = br#"{"$type":"sh.tangled.repo.issue","body":"a bug","createdAt":"","repo":"at://did:plc:scallop/sh.tangled.repo/limpet","repoDid":"did:plc:scallop","title":"a bug"}"#;
722 let patched = synthesize_created_at(json, "2025-08-01T12:00:00.000000Z")
723 .expect("empty createdAt must be filled");
724 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.issue"), &patched)
725 .expect("issue must legacy-decode after createdAt fill");
726 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
727 let canon = match decoded {
728 DecodedRecord::Canon(r) => r,
729 DecodedRecord::Legacy(l) => upgrade(l, &resolver).await.expect("upgrade"),
730 };
731 match canon {
732 Record::Issue(i) => {
733 assert_eq!(AsRef::<str>::as_ref(&i.title), "a bug");
734 assert_eq!(i.repo.as_ref(), "did:plc:scallop");
735 }
736 other => panic!("expected issue, got {other:?}"),
737 }
738 }
739
740 #[tokio::test]
741 async fn try_decode_recovers_canon_pull_with_duplicate_dollar_type() {
742 let json = br#"{"$type":"sh.tangled.repo.pull","createdAt":"2026-05-01T00:00:00Z","title":"meow","target":{"branch":"main","repo":"at://did:plc:scallop/sh.tangled.repo/limpet"},"rounds":[],"$type":"sh.tangled.repo.pull"}"#;
743 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.pull"), json)
744 .expect("duplicate $type pull must normalize-recover");
745 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
746 resolver
747 .observe(
748 did("did:plc:scallop"),
749 rkey("limpet"),
750 Some(did("did:plc:scallop")),
751 )
752 .await;
753 let canon = match decoded {
754 DecodedRecord::Canon(r) => r,
755 DecodedRecord::Legacy(l) => upgrade(l, &resolver).await.expect("upgrade"),
756 };
757 match canon {
758 Record::Pull(p) => assert_eq!(AsRef::<str>::as_ref(&p.title), "meow"),
759 other => panic!("expected pull, got {other:?}"),
760 }
761 }
762
763 #[tokio::test]
764 async fn upgrade_issue_uses_repo_did_directly() {
765 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
766 let json = br#"{"$type":"sh.tangled.repo.issue","repoDid":"did:plc:scallop","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
767 let legacy =
768 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.issue"), json).expect("decode");
769 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
770 match canon {
771 Record::Issue(i) => assert_eq!(i.repo, did("did:plc:scallop")),
772 other => panic!("expected canon issue, got {other:?}"),
773 }
774 }
775
776 #[tokio::test]
777 async fn upgrade_issue_resolves_repo_uri_via_observed_resolver() {
778 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
779 let owner = did("did:plc:nel");
780 let key = rkey("abcabcabcabcz");
781 resolver
782 .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop")))
783 .await;
784 let json = br#"{"$type":"sh.tangled.repo.issue","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
785 let legacy =
786 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.issue"), json).expect("decode");
787 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
788 match canon {
789 Record::Issue(i) => assert_eq!(i.repo, did("did:plc:scallop")),
790 other => panic!("expected canon issue, got {other:?}"),
791 }
792 }
793
794 #[tokio::test]
795 async fn upgrade_issue_drops_when_resolver_cannot_map() {
796 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
797 let json = br#"{"$type":"sh.tangled.repo.issue","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
798 let legacy =
799 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.issue"), json).expect("decode");
800 assert!(
801 upgrade(legacy, &resolver).await.is_none(),
802 "no resolver entry and no repoDid means the canon Did cannot be constructed",
803 );
804 }
805
806 #[tokio::test]
807 async fn upgrade_pull_propagates_target_resolution() {
808 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
809 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","rounds":[],"target":{"branch":"main","repoDid":"did:plc:scallop"}}"#;
810 let legacy =
811 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
812 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
813 match canon {
814 Record::Pull(p) => {
815 assert_eq!(p.target.repo, did("did:plc:scallop"));
816 assert!(p.source.is_none());
817 }
818 other => panic!("expected canon pull, got {other:?}"),
819 }
820 }
821
822 #[tokio::test]
823 async fn upgrade_pull_pre_rounds_synthesizes_round_from_top_level_patch_blob() {
824 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
825 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","target":{"branch":"main","repoDid":"did:plc:scallop"},"patchBlob":{"$type":"blob","mimeType":"application/gzip","ref":{"$link":"bafkreibpatvbeajtwzlr4jwr4s2hnwo5l7sgdbfnqu6n7ctd2bcbtluw4a"},"size":920}}"#;
826 let legacy =
827 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
828 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
829 match canon {
830 Record::Pull(p) => {
831 assert_eq!(
832 p.rounds.len(),
833 1,
834 "pre-rounds wire must yield exactly one synthesized round"
835 );
836 assert_eq!(
837 p.rounds[0].patch_blob.blob().mime_type.as_ref(),
838 "application/gzip"
839 );
840 assert_eq!(p.rounds[0].created_at, p.created_at);
841 }
842 other => panic!("expected canon pull, got {other:?}"),
843 }
844 }
845
846 #[tokio::test]
847 async fn upgrade_pull_omits_round_when_neither_rounds_nor_patch_blob_present() {
848 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
849 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","target":{"branch":"main","repoDid":"did:plc:scallop"}}"#;
850 let legacy =
851 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
852 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
853 match canon {
854 Record::Pull(p) => assert!(p.rounds.is_empty()),
855 other => panic!("expected canon pull, got {other:?}"),
856 }
857 }
858
859 #[tokio::test]
860 async fn upgrade_public_key_renames_created_to_created_at() {
861 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
862 let json = br#"{"$type":"sh.tangled.publicKey","created":"2025-04-15T18:35:38Z","key":"ssh-ed25519 AAAA","name":"laptop"}"#;
863 let legacy =
864 LegacyRecord::from_json_bytes(&nsid("sh.tangled.publicKey"), json).expect("decode");
865 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
866 match canon {
867 Record::PublicKey(k) => {
868 assert_eq!(k.created_at.as_str(), "2025-04-15T18:35:38Z");
869 assert_eq!(k.key.as_str(), "ssh-ed25519 AAAA");
870 assert_eq!(k.name.as_str(), "laptop");
871 }
872 other => panic!("expected canon publicKey, got {other:?}"),
873 }
874 }
875
876 #[tokio::test]
877 async fn upgrade_repo_renames_added_at_and_drops_owner() {
878 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
879 let json = br#"{"$type":"sh.tangled.repo","addedAt":"2025-03-21T10:18:58Z","description":"hi","knot":"knot1.tangled.sh","name":"site","owner":"did:plc:nel"}"#;
880 let legacy = LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo"), json).expect("decode");
881 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
882 match canon {
883 Record::Repo(r) => {
884 assert_eq!(r.created_at.as_str(), "2025-03-21T10:18:58Z");
885 assert_eq!(r.description.as_deref(), Some("hi"));
886 assert_eq!(r.knot.as_str(), "knot1.tangled.sh");
887 assert_eq!(r.name.as_deref(), Some("site"));
888 assert!(r.repo_did.is_none(), "legacy repos have no repo_did");
889 }
890 other => panic!("expected canon repo, got {other:?}"),
891 }
892 }
893
894 #[tokio::test]
895 async fn upgrade_knot_member_renames_added_at_and_member() {
896 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
897 let json = br#"{"$type":"sh.tangled.knot.member","addedAt":"2025-03-31T05:14:09Z","domain":"knot.example","member":"did:plc:nel"}"#;
898 let legacy =
899 LegacyRecord::from_json_bytes(&nsid("sh.tangled.knot.member"), json).expect("decode");
900 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
901 match canon {
902 Record::KnotMember(m) => {
903 assert_eq!(m.created_at.as_str(), "2025-03-31T05:14:09Z");
904 assert_eq!(m.domain.as_str(), "knot.example");
905 assert_eq!(m.subject, did("did:plc:nel"));
906 }
907 other => panic!("expected canon knot.member, got {other:?}"),
908 }
909 }
910
911 #[tokio::test]
912 async fn legacy_pull_target_with_empty_repo_did_treats_as_none() {
913 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
914 resolver
915 .observe(
916 did("did:plc:nel"),
917 rkey("abcabcabcabcz"),
918 Some(did("did:plc:scallop")),
919 )
920 .await;
921 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","rounds":[],"target":{"branch":"main","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz","repoDid":""}}"#;
922 let legacy =
923 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
924 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
925 match canon {
926 Record::Pull(p) => assert_eq!(p.target.repo, did("did:plc:scallop")),
927 other => panic!("expected canon pull, got {other:?}"),
928 }
929 }
930
931 #[tokio::test]
932 async fn try_decode_recovers_publickey_with_created_field() {
933 let json = br#"{"$type":"sh.tangled.publicKey","created":"2025-04-15T18:35:38Z","key":"k","name":"n"}"#;
934 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.publicKey"), json)
935 .expect("legacy publicKey must decode");
936 assert!(matches!(
937 decoded,
938 DecodedRecord::Legacy(LegacyRecord::PublicKey(_))
939 ));
940 }
941
942 #[tokio::test]
943 async fn try_decode_recovers_repo_with_added_at_field() {
944 let json = br#"{"$type":"sh.tangled.repo","addedAt":"2025-03-21T10:18:58Z","knot":"knot1.tangled.sh","owner":"did:plc:nel"}"#;
945 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo"), json)
946 .expect("legacy repo must decode");
947 assert!(matches!(
948 decoded,
949 DecodedRecord::Legacy(LegacyRecord::Repo(_))
950 ));
951 }
952
953 #[tokio::test]
954 async fn upgrade_pull_source_repo_resolution_is_independent_of_target() {
955 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
956 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","rounds":[],"target":{"branch":"main","repoDid":"did:plc:scallop"},"source":{"branch":"feat","repo":"at://did:plc:nel/sh.tangled.repo/missing"}}"#;
957 let legacy =
958 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
959 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
960 match canon {
961 Record::Pull(p) => {
962 assert_eq!(p.target.repo, did("did:plc:scallop"));
963 let source = p.source.expect("source struct retained");
964 assert_eq!(source.branch.as_str(), "feat");
965 assert!(
966 source.repo.is_none(),
967 "unresolvable source repo at-uri leaves the source.repo None rather than dropping the whole pull",
968 );
969 }
970 other => panic!("expected canon pull, got {other:?}"),
971 }
972 }
973
974 #[tokio::test]
975 async fn upgrade_ref_update_renames_repo_did_to_repo() {
976 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
977 let json = br#"{"$type":"sh.tangled.git.refUpdate","ref":"refs/heads/main","committerDid":"did:plc:olaren","repoDid":"did:plc:scallop","oldSha":"0000000000000000000000000000000000000000","newSha":"1111111111111111111111111111111111111111","meta":{"isDefaultRef":true,"commitCount":{}}}"#;
978 let legacy =
979 LegacyRecord::from_json_bytes(&nsid("sh.tangled.git.refUpdate"), json).expect("decode");
980 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
981 match canon {
982 Record::RefUpdate(r) => assert_eq!(r.repo, did("did:plc:scallop")),
983 other => panic!("expected canon ref update, got {other:?}"),
984 }
985 }
986
987 #[tokio::test]
988 async fn upgrade_star_prefers_subject_did_over_subject_uri() {
989 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
990 let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.string/k1","subjectDid":"did:plc:scallop"}"#;
991 let legacy =
992 LegacyRecord::from_json_bytes(&nsid("sh.tangled.feed.star"), json).expect("decode");
993 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
994 match canon {
995 Record::Star(s) => match s.subject {
996 StarSubject::Repo(r) => assert_eq!(r.did, did("did:plc:scallop")),
997 StarSubject::String(_) => panic!("subjectDid must win"),
998 },
999 other => panic!("expected canon star, got {other:?}"),
1000 }
1001 }
1002
1003 #[tokio::test]
1004 async fn upgrade_star_falls_back_to_string_when_repo_uri_not_in_cache() {
1005 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
1006 let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#;
1007 let legacy =
1008 LegacyRecord::from_json_bytes(&nsid("sh.tangled.feed.star"), json).expect("decode");
1009 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
1010 match canon {
1011 Record::Star(s) => match s.subject {
1012 StarSubject::String(s) => assert_eq!(
1013 s.uri.as_ref(),
1014 "at://did:plc:nel/sh.tangled.repo/abcabcabcabcz",
1015 "cache-miss on repo uri preserves the uri under the #string variant for later normalization",
1016 ),
1017 StarSubject::Repo(_) => panic!("cold cache must not upgrade to Repo variant"),
1018 },
1019 other => panic!("expected canon star, got {other:?}"),
1020 }
1021 }
1022
1023 #[tokio::test]
1024 async fn upgrade_star_uses_cached_repo_did_when_observed() {
1025 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
1026 let owner = did("did:plc:nel");
1027 let key = rkey("abcabcabcabcz");
1028 resolver
1029 .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop")))
1030 .await;
1031 let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#;
1032 let legacy =
1033 LegacyRecord::from_json_bytes(&nsid("sh.tangled.feed.star"), json).expect("decode");
1034 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
1035 match canon {
1036 Record::Star(s) => match s.subject {
1037 StarSubject::Repo(r) => assert_eq!(r.did, did("did:plc:scallop")),
1038 StarSubject::String(_) => panic!("observed cache must upgrade to Repo variant"),
1039 },
1040 other => panic!("expected canon star, got {other:?}"),
1041 }
1042 }
1043
1044 #[tokio::test]
1045 async fn upgrade_collaborator_requires_repo_did() {
1046 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
1047 let with_did = br#"{"$type":"sh.tangled.repo.collaborator","createdAt":"2026-05-01T00:00:00Z","subject":"did:plc:lyna","repoDid":"did:plc:scallop"}"#;
1048 let canon = upgrade(
1049 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.collaborator"), with_did)
1050 .expect("decode"),
1051 &resolver,
1052 )
1053 .await
1054 .expect("upgrade");
1055 match canon {
1056 Record::Collaborator(c) => assert_eq!(c.repo, did("did:plc:scallop")),
1057 other => panic!("expected canon collaborator, got {other:?}"),
1058 }
1059
1060 let no_resolution = br#"{"$type":"sh.tangled.repo.collaborator","createdAt":"2026-05-01T00:00:00Z","subject":"did:plc:lyna","repo":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#;
1061 let legacy =
1062 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.collaborator"), no_resolution)
1063 .expect("decode");
1064 assert!(upgrade(legacy, &resolver).await.is_none());
1065 }
1066}