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