This repository has no description
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 changed_files: None,
443 committer_did: l.committer_did,
444 meta: l.meta,
445 new_sha: l.new_sha,
446 old_sha: l.old_sha,
447 owner_did: l.owner_did,
448 push_options: None,
449 r#ref: l.r#ref,
450 repo: l.repo_did,
451 extra_data: l.extra_data,
452 }
453}
454
455async fn upgrade_star(
456 l: LegacyStar<DefaultStr>,
457 resolver: &RepoIdResolver,
458) -> Option<Star<DefaultStr>> {
459 let subject = if let Some(did) = l.subject_did {
460 StarSubject::Repo(alloc::boxed::Box::new(StarRepo {
461 did,
462 extra_data: None,
463 }))
464 } else {
465 let uri = l.subject?;
466 let resolved = if is_repo_at_uri(&uri) {
467 cached_repo_did(resolver, &uri).await
468 } else {
469 None
470 };
471 match resolved {
472 Some(did) => StarSubject::Repo(alloc::boxed::Box::new(StarRepo {
473 did,
474 extra_data: None,
475 })),
476 None => StarSubject::String(alloc::boxed::Box::new(StarString {
477 uri,
478 extra_data: None,
479 })),
480 }
481 };
482 Some(Star {
483 created_at: l.created_at,
484 subject,
485 extra_data: l.extra_data,
486 })
487}
488
489async fn cached_repo_did(
490 resolver: &RepoIdResolver,
491 uri: &jacquard_common::types::string::AtUri<DefaultStr>,
492) -> Option<Did<DefaultStr>> {
493 let owner = match uri.authority() {
494 AtIdentifier::Did(d) => d.clone().into_static(),
495 AtIdentifier::Handle(_) => return None,
496 };
497 let rkey: Rkey<DefaultStr> = uri.rkey()?.clone().into_static();
498 match resolver.cached_resolution(&owner, &rkey).await? {
499 Resolution::Mapped(did) => Some(did),
500 Resolution::NoRepoDid | Resolution::Unresolvable => None,
501 }
502}
503
504extern crate alloc;
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::RepoIdResolver;
510 use bobbin_runtime::RuntimeHasher;
511 use bobbin_types::edges::Record;
512 use jacquard_common::DefaultStr;
513 use jacquard_common::types::did::Did;
514 use jacquard_common::types::recordkey::Rkey;
515
516 fn did(s: &str) -> Did<DefaultStr> {
517 Did::new_owned(s).unwrap()
518 }
519
520 fn rkey(s: &str) -> Rkey<DefaultStr> {
521 Rkey::new_owned(s).unwrap()
522 }
523
524 fn nsid(s: &'static str) -> Nsid<DefaultStr> {
525 Nsid::new_static(s).unwrap()
526 }
527
528 #[test]
529 fn legacy_decode_routes_through_try_decode_for_known_nsids() {
530 let json = br#"{"$type":"sh.tangled.repo.issue","repoDid":"did:plc:squid","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
531 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.issue"), json)
532 .expect("legacy issue must decode");
533 assert!(matches!(
534 decoded,
535 DecodedRecord::Legacy(LegacyRecord::Issue(_))
536 ));
537 }
538
539 #[test]
540 fn canon_decode_wins_when_wire_matches_new_shape() {
541 let json = br#"{"$type":"sh.tangled.repo.issue","repo":"did:plc:squid","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
542 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.issue"), json)
543 .expect("canon issue must decode");
544 match decoded {
545 DecodedRecord::Canon(Record::Issue(i)) => {
546 assert_eq!(i.repo.as_ref(), "did:plc:squid")
547 }
548 other => panic!("expected canon issue, got {other:?}"),
549 }
550 }
551
552 #[test]
553 fn scrub_returns_none_for_unknown_nsid() {
554 let json = br#"{"$type":"sh.tangled.repo.issue","preferredHandle":""}"#;
555 assert!(scrub_record_bytes(&nsid("sh.tangled.repo.issue"), json).is_none());
556 }
557
558 #[test]
559 fn scrub_returns_none_when_target_field_is_non_empty() {
560 let json =
561 br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":"nel.pet"}"#;
562 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json).is_none());
563 }
564
565 #[test]
566 fn scrub_returns_none_when_target_field_is_absent() {
567 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true}"#;
568 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json).is_none());
569 }
570
571 #[test]
572 fn scrub_returns_none_for_non_string_value() {
573 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":42}"#;
574 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json).is_none());
575 }
576
577 #[test]
578 fn scrub_returns_none_for_non_object_json() {
579 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"[]").is_none());
580 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"null").is_none());
581 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"123").is_none());
582 }
583
584 #[test]
585 fn scrub_returns_none_for_invalid_json() {
586 assert!(scrub_record_bytes(&nsid("sh.tangled.actor.profile"), b"{not json").is_none());
587 }
588
589 #[test]
590 fn scrub_drops_empty_preferred_handle_and_preserves_other_fields() {
591 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":"","description":"hi"}"#;
592 let scrubbed = scrub_record_bytes(&nsid("sh.tangled.actor.profile"), json)
593 .expect("empty preferredHandle must trigger scrub");
594 let value: serde_json::Value = serde_json::from_slice(&scrubbed).expect("valid json");
595 let obj = value.as_object().expect("object");
596 assert!(!obj.contains_key("preferredHandle"));
597 assert_eq!(obj.get("bluesky"), Some(&serde_json::json!(true)));
598 assert_eq!(obj.get("description"), Some(&serde_json::json!("hi")));
599 assert_eq!(
600 obj.get("$type"),
601 Some(&serde_json::json!("sh.tangled.actor.profile"))
602 );
603 }
604
605 #[test]
606 fn scrub_replaces_null_arrays_with_empty_for_label_op() {
607 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"}"#;
608 let scrubbed = scrub_record_bytes(&nsid("sh.tangled.label.op"), json)
609 .expect("null delete must trigger scrub");
610 let value: serde_json::Value = serde_json::from_slice(&scrubbed).expect("valid json");
611 let obj = value.as_object().expect("object");
612 assert_eq!(obj.get("delete"), Some(&serde_json::json!([])));
613 assert!(obj.get("add").is_some_and(|v| v.is_array()));
614 }
615
616 #[test]
617 fn scrub_passes_through_when_label_op_arrays_are_non_null() {
618 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"}"#;
619 assert!(scrub_record_bytes(&nsid("sh.tangled.label.op"), json).is_none());
620 }
621
622 #[test]
623 fn try_decode_recovers_label_op_with_null_delete() {
624 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"}"#;
625 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.label.op"), json)
626 .expect("label.op with null delete must scrub-recover");
627 assert!(matches!(decoded, DecodedRecord::Canon(Record::LabelOp(_))));
628 }
629
630 #[test]
631 fn try_decode_recovers_profile_with_empty_preferred_handle() {
632 let json = br#"{"$type":"sh.tangled.actor.profile","bluesky":true,"preferredHandle":"","description":"hi"}"#;
633 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.actor.profile"), json)
634 .expect("profile with empty preferredHandle must scrub-recover");
635 match decoded {
636 DecodedRecord::Canon(Record::Profile(p)) => {
637 assert!(p.preferred_handle.is_none());
638 assert_eq!(p.description.as_deref(), Some("hi"));
639 }
640 other => panic!("expected canon profile, got {other:?}"),
641 }
642 }
643
644 #[test]
645 fn legacy_decode_passes_through_for_unaffected_nsids() {
646 let json = br#"{"$type":"sh.tangled.graph.follow","subject":"did:plc:bailey","createdAt":"2026-05-01T00:00:00Z"}"#;
647 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.graph.follow"), json)
648 .expect("follow has no legacy form, must decode canon");
649 assert!(matches!(decoded, DecodedRecord::Canon(Record::Follow(_))));
650 }
651
652 #[test]
653 fn normalize_returns_none_for_invalid_json() {
654 assert!(normalize_record_fields(b"{not json").is_none());
655 }
656
657 #[test]
658 fn normalize_drops_repeated_dollar_type_key() {
659 let json =
660 br#"{"$type":"sh.tangled.repo.pull","title":"t","$type":"sh.tangled.repo.pull"}"#;
661 let normalized =
662 normalize_record_fields(json).expect("duplicate key must produce normalized output");
663 let value: serde_json::Value = serde_json::from_slice(&normalized).expect("valid json");
664 let obj = value.as_object().expect("object");
665 assert_eq!(obj.len(), 2);
666 assert_eq!(
667 obj.get("$type"),
668 Some(&serde_json::json!("sh.tangled.repo.pull"))
669 );
670 assert_eq!(obj.get("title"), Some(&serde_json::json!("t")));
671 }
672
673 #[test]
674 fn normalize_keeps_last_value_for_repeated_keys() {
675 let json = br#"{"$type":"sh.tangled.repo.issue","$type":"sh.tangled.repo.pull"}"#;
676 let normalized =
677 normalize_record_fields(json).expect("duplicate key must produce normalized output");
678 let value: serde_json::Value = serde_json::from_slice(&normalized).expect("valid json");
679 assert_eq!(
680 value.get("$type"),
681 Some(&serde_json::json!("sh.tangled.repo.pull")),
682 );
683 }
684
685 #[test]
686 fn synthesize_fills_empty_created_at_with_fallback() {
687 let json = br#"{"$type":"sh.tangled.repo.issue","title":"meow","createdAt":""}"#;
688 let patched = synthesize_created_at(json, "2026-05-01T00:00:00.000000Z")
689 .expect("empty createdAt must be filled");
690 let value: serde_json::Value = serde_json::from_slice(&patched).expect("valid json");
691 assert_eq!(
692 value.get("createdAt"),
693 Some(&serde_json::json!("2026-05-01T00:00:00.000000Z")),
694 );
695 }
696
697 #[test]
698 fn synthesize_fills_missing_created_at_with_fallback() {
699 let json = br#"{"$type":"sh.tangled.repo.issue","title":"meow"}"#;
700 let patched = synthesize_created_at(json, "2026-05-01T00:00:00.000000Z")
701 .expect("missing createdAt must be filled");
702 let value: serde_json::Value = serde_json::from_slice(&patched).expect("valid json");
703 assert_eq!(
704 value.get("createdAt"),
705 Some(&serde_json::json!("2026-05-01T00:00:00.000000Z")),
706 );
707 }
708
709 #[test]
710 fn synthesize_returns_none_when_created_at_already_set() {
711 let json = br#"{"$type":"sh.tangled.repo.issue","createdAt":"2026-05-01T00:00:00Z"}"#;
712 assert!(synthesize_created_at(json, "2026-04-01T00:00:00Z").is_none());
713 }
714
715 #[test]
716 fn synthesize_returns_none_for_non_string_created_at() {
717 let json = br#"{"$type":"sh.tangled.repo.issue","createdAt":null}"#;
718 assert!(synthesize_created_at(json, "2026-04-01T00:00:00Z").is_none());
719 }
720
721 #[tokio::test]
722 async fn try_decode_recovers_legacy_issue_after_synthesized_created_at() {
723 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"}"#;
724 let patched = synthesize_created_at(json, "2025-08-01T12:00:00.000000Z")
725 .expect("empty createdAt must be filled");
726 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.issue"), &patched)
727 .expect("issue must legacy-decode after createdAt fill");
728 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
729 let canon = match decoded {
730 DecodedRecord::Canon(r) => r,
731 DecodedRecord::Legacy(l) => upgrade(l, &resolver).await.expect("upgrade"),
732 };
733 match canon {
734 Record::Issue(i) => {
735 assert_eq!(AsRef::<str>::as_ref(&i.title), "a bug");
736 assert_eq!(i.repo.as_ref(), "did:plc:scallop");
737 }
738 other => panic!("expected issue, got {other:?}"),
739 }
740 }
741
742 #[tokio::test]
743 async fn try_decode_recovers_canon_pull_with_duplicate_dollar_type() {
744 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"}"#;
745 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo.pull"), json)
746 .expect("duplicate $type pull must normalize-recover");
747 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
748 resolver
749 .observe(
750 did("did:plc:scallop"),
751 rkey("limpet"),
752 Some(did("did:plc:scallop")),
753 )
754 .await;
755 let canon = match decoded {
756 DecodedRecord::Canon(r) => r,
757 DecodedRecord::Legacy(l) => upgrade(l, &resolver).await.expect("upgrade"),
758 };
759 match canon {
760 Record::Pull(p) => assert_eq!(AsRef::<str>::as_ref(&p.title), "meow"),
761 other => panic!("expected pull, got {other:?}"),
762 }
763 }
764
765 #[tokio::test]
766 async fn upgrade_issue_uses_repo_did_directly() {
767 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
768 let json = br#"{"$type":"sh.tangled.repo.issue","repoDid":"did:plc:scallop","title":"t","createdAt":"2026-05-01T00:00:00Z"}"#;
769 let legacy =
770 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.issue"), json).expect("decode");
771 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
772 match canon {
773 Record::Issue(i) => assert_eq!(i.repo, did("did:plc:scallop")),
774 other => panic!("expected canon issue, got {other:?}"),
775 }
776 }
777
778 #[tokio::test]
779 async fn upgrade_issue_resolves_repo_uri_via_observed_resolver() {
780 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
781 let owner = did("did:plc:nel");
782 let key = rkey("abcabcabcabcz");
783 resolver
784 .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop")))
785 .await;
786 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"}"#;
787 let legacy =
788 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.issue"), json).expect("decode");
789 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
790 match canon {
791 Record::Issue(i) => assert_eq!(i.repo, did("did:plc:scallop")),
792 other => panic!("expected canon issue, got {other:?}"),
793 }
794 }
795
796 #[tokio::test]
797 async fn upgrade_issue_drops_when_resolver_cannot_map() {
798 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
799 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"}"#;
800 let legacy =
801 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.issue"), json).expect("decode");
802 assert!(
803 upgrade(legacy, &resolver).await.is_none(),
804 "no resolver entry and no repoDid means the canon Did cannot be constructed",
805 );
806 }
807
808 #[tokio::test]
809 async fn upgrade_pull_propagates_target_resolution() {
810 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
811 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","rounds":[],"target":{"branch":"main","repoDid":"did:plc:scallop"}}"#;
812 let legacy =
813 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
814 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
815 match canon {
816 Record::Pull(p) => {
817 assert_eq!(p.target.repo, did("did:plc:scallop"));
818 assert!(p.source.is_none());
819 }
820 other => panic!("expected canon pull, got {other:?}"),
821 }
822 }
823
824 #[tokio::test]
825 async fn upgrade_pull_pre_rounds_synthesizes_round_from_top_level_patch_blob() {
826 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
827 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}}"#;
828 let legacy =
829 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
830 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
831 match canon {
832 Record::Pull(p) => {
833 assert_eq!(
834 p.rounds.len(),
835 1,
836 "pre-rounds wire must yield exactly one synthesized round"
837 );
838 assert_eq!(
839 p.rounds[0].patch_blob.blob().mime_type.as_ref(),
840 "application/gzip"
841 );
842 assert_eq!(p.rounds[0].created_at, p.created_at);
843 }
844 other => panic!("expected canon pull, got {other:?}"),
845 }
846 }
847
848 #[tokio::test]
849 async fn upgrade_pull_omits_round_when_neither_rounds_nor_patch_blob_present() {
850 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
851 let json = br#"{"$type":"sh.tangled.repo.pull","title":"t","createdAt":"2026-05-01T00:00:00Z","target":{"branch":"main","repoDid":"did:plc:scallop"}}"#;
852 let legacy =
853 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
854 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
855 match canon {
856 Record::Pull(p) => assert!(p.rounds.is_empty()),
857 other => panic!("expected canon pull, got {other:?}"),
858 }
859 }
860
861 #[tokio::test]
862 async fn upgrade_public_key_renames_created_to_created_at() {
863 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
864 let json = br#"{"$type":"sh.tangled.publicKey","created":"2025-04-15T18:35:38Z","key":"ssh-ed25519 AAAA","name":"laptop"}"#;
865 let legacy =
866 LegacyRecord::from_json_bytes(&nsid("sh.tangled.publicKey"), json).expect("decode");
867 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
868 match canon {
869 Record::PublicKey(k) => {
870 assert_eq!(k.created_at.as_str(), "2025-04-15T18:35:38Z");
871 assert_eq!(k.key.as_str(), "ssh-ed25519 AAAA");
872 assert_eq!(k.name.as_str(), "laptop");
873 }
874 other => panic!("expected canon publicKey, got {other:?}"),
875 }
876 }
877
878 #[tokio::test]
879 async fn upgrade_repo_renames_added_at_and_drops_owner() {
880 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
881 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"}"#;
882 let legacy = LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo"), json).expect("decode");
883 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
884 match canon {
885 Record::Repo(r) => {
886 assert_eq!(r.created_at.as_str(), "2025-03-21T10:18:58Z");
887 assert_eq!(r.description.as_deref(), Some("hi"));
888 assert_eq!(r.knot.as_str(), "knot1.tangled.sh");
889 assert_eq!(r.name.as_deref(), Some("site"));
890 assert!(r.repo_did.is_none(), "legacy repos have no repo_did");
891 }
892 other => panic!("expected canon repo, got {other:?}"),
893 }
894 }
895
896 #[tokio::test]
897 async fn upgrade_knot_member_renames_added_at_and_member() {
898 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
899 let json = br#"{"$type":"sh.tangled.knot.member","addedAt":"2025-03-31T05:14:09Z","domain":"knot.example","member":"did:plc:nel"}"#;
900 let legacy =
901 LegacyRecord::from_json_bytes(&nsid("sh.tangled.knot.member"), json).expect("decode");
902 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
903 match canon {
904 Record::KnotMember(m) => {
905 assert_eq!(m.created_at.as_str(), "2025-03-31T05:14:09Z");
906 assert_eq!(m.domain.as_str(), "knot.example");
907 assert_eq!(m.subject, did("did:plc:nel"));
908 }
909 other => panic!("expected canon knot.member, got {other:?}"),
910 }
911 }
912
913 #[tokio::test]
914 async fn legacy_pull_target_with_empty_repo_did_treats_as_none() {
915 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
916 resolver
917 .observe(
918 did("did:plc:nel"),
919 rkey("abcabcabcabcz"),
920 Some(did("did:plc:scallop")),
921 )
922 .await;
923 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":""}}"#;
924 let legacy =
925 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
926 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
927 match canon {
928 Record::Pull(p) => assert_eq!(p.target.repo, did("did:plc:scallop")),
929 other => panic!("expected canon pull, got {other:?}"),
930 }
931 }
932
933 #[tokio::test]
934 async fn try_decode_recovers_publickey_with_created_field() {
935 let json = br#"{"$type":"sh.tangled.publicKey","created":"2025-04-15T18:35:38Z","key":"k","name":"n"}"#;
936 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.publicKey"), json)
937 .expect("legacy publicKey must decode");
938 assert!(matches!(
939 decoded,
940 DecodedRecord::Legacy(LegacyRecord::PublicKey(_))
941 ));
942 }
943
944 #[tokio::test]
945 async fn try_decode_recovers_repo_with_added_at_field() {
946 let json = br#"{"$type":"sh.tangled.repo","addedAt":"2025-03-21T10:18:58Z","knot":"knot1.tangled.sh","owner":"did:plc:nel"}"#;
947 let decoded = DecodedRecord::try_decode(&nsid("sh.tangled.repo"), json)
948 .expect("legacy repo must decode");
949 assert!(matches!(
950 decoded,
951 DecodedRecord::Legacy(LegacyRecord::Repo(_))
952 ));
953 }
954
955 #[tokio::test]
956 async fn upgrade_pull_source_repo_resolution_is_independent_of_target() {
957 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
958 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"}}"#;
959 let legacy =
960 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.pull"), json).expect("decode");
961 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
962 match canon {
963 Record::Pull(p) => {
964 assert_eq!(p.target.repo, did("did:plc:scallop"));
965 let source = p.source.expect("source struct retained");
966 assert_eq!(source.branch.as_str(), "feat");
967 assert!(
968 source.repo.is_none(),
969 "unresolvable source repo at-uri leaves the source.repo None rather than dropping the whole pull",
970 );
971 }
972 other => panic!("expected canon pull, got {other:?}"),
973 }
974 }
975
976 #[tokio::test]
977 async fn upgrade_ref_update_renames_repo_did_to_repo() {
978 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
979 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":{}}}"#;
980 let legacy =
981 LegacyRecord::from_json_bytes(&nsid("sh.tangled.git.refUpdate"), json).expect("decode");
982 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
983 match canon {
984 Record::RefUpdate(r) => {
985 assert_eq!(r.repo, did("did:plc:scallop"));
986 assert!(r.changed_files.is_none());
987 assert!(r.push_options.is_none());
988 }
989 other => panic!("expected canon ref update, got {other:?}"),
990 }
991 }
992
993 #[tokio::test]
994 async fn upgrade_star_prefers_subject_did_over_subject_uri() {
995 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
996 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"}"#;
997 let legacy =
998 LegacyRecord::from_json_bytes(&nsid("sh.tangled.feed.star"), json).expect("decode");
999 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
1000 match canon {
1001 Record::Star(s) => match s.subject {
1002 StarSubject::Repo(r) => assert_eq!(r.did, did("did:plc:scallop")),
1003 StarSubject::String(_) => panic!("subjectDid must win"),
1004 },
1005 other => panic!("expected canon star, got {other:?}"),
1006 }
1007 }
1008
1009 #[tokio::test]
1010 async fn upgrade_star_falls_back_to_string_when_repo_uri_not_in_cache() {
1011 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
1012 let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#;
1013 let legacy =
1014 LegacyRecord::from_json_bytes(&nsid("sh.tangled.feed.star"), json).expect("decode");
1015 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
1016 match canon {
1017 Record::Star(s) => match s.subject {
1018 StarSubject::String(s) => assert_eq!(
1019 s.uri.as_ref(),
1020 "at://did:plc:nel/sh.tangled.repo/abcabcabcabcz",
1021 "cache-miss on repo uri preserves the uri under the #string variant for later normalization",
1022 ),
1023 StarSubject::Repo(_) => panic!("cold cache must not upgrade to Repo variant"),
1024 },
1025 other => panic!("expected canon star, got {other:?}"),
1026 }
1027 }
1028
1029 #[tokio::test]
1030 async fn upgrade_star_uses_cached_repo_did_when_observed() {
1031 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
1032 let owner = did("did:plc:nel");
1033 let key = rkey("abcabcabcabcz");
1034 resolver
1035 .observe(owner.clone(), key.clone(), Some(did("did:plc:scallop")))
1036 .await;
1037 let json = br#"{"$type":"sh.tangled.feed.star","createdAt":"2026-05-01T00:00:00Z","subject":"at://did:plc:nel/sh.tangled.repo/abcabcabcabcz"}"#;
1038 let legacy =
1039 LegacyRecord::from_json_bytes(&nsid("sh.tangled.feed.star"), json).expect("decode");
1040 let canon = upgrade(legacy, &resolver).await.expect("upgrade");
1041 match canon {
1042 Record::Star(s) => match s.subject {
1043 StarSubject::Repo(r) => assert_eq!(r.did, did("did:plc:scallop")),
1044 StarSubject::String(_) => panic!("observed cache must upgrade to Repo variant"),
1045 },
1046 other => panic!("expected canon star, got {other:?}"),
1047 }
1048 }
1049
1050 #[tokio::test]
1051 async fn upgrade_collaborator_requires_repo_did() {
1052 let resolver = RepoIdResolver::detached(RuntimeHasher::default());
1053 let with_did = br#"{"$type":"sh.tangled.repo.collaborator","createdAt":"2026-05-01T00:00:00Z","subject":"did:plc:lyna","repoDid":"did:plc:scallop"}"#;
1054 let canon = upgrade(
1055 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.collaborator"), with_did)
1056 .expect("decode"),
1057 &resolver,
1058 )
1059 .await
1060 .expect("upgrade");
1061 match canon {
1062 Record::Collaborator(c) => assert_eq!(c.repo, did("did:plc:scallop")),
1063 other => panic!("expected canon collaborator, got {other:?}"),
1064 }
1065
1066 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"}"#;
1067 let legacy =
1068 LegacyRecord::from_json_bytes(&nsid("sh.tangled.repo.collaborator"), no_resolution)
1069 .expect("decode");
1070 assert!(upgrade(legacy, &resolver).await.is_none());
1071 }
1072}