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