Skip to main content

techne_sdk/
put.rs

1//! `put`'s plan (techne P13.2): the records an app writes, keyed, addressed and in the order they go
2//! out. The TypeScript `put` (`sdk/src/put`) sends what this answers; nothing here does I/O.
3//!
4//! - **Private first, public last.** A record is private when it names a `space`, or when its
5//!   collection has no public home (the placement manifest's `public` list) and is either a lexicon
6//!   type or any other name in the platform's own namespaces (`pub.techne`, and `coop.lexicon` it
7//!   was renamed out of: a typo after the namespace, other letter case; a typo in the namespace
8//!   itself is not caught): it goes through the
9//!   platform's private batch write (`permissioned.putRecords`). Every other record goes to the
10//!   user's PDS in one `applyWrites`, which is atomic in one repo. That includes a type in another
11//!   namespace the platform has no lexicon for (another app's own record, P18.1, lucian 2026-10-24):
12//!   it is the user's record in the user's repo, and the platform answers it "not curated: no
13//!   lexicon". A name from before a rename (N1) is placed, keyed and curated as the type it is now,
14//!   and written under the name given: an old build's public record stays public. Public and private
15//!   writes cannot share a transaction, so the private ones land first
16//!   and the public ones last: a reader that sees the public record finds what it points to.
17//! - **Deterministic rkeys**, so a put sent again after a failure lands on the same keys and the
18//!   records that already landed are written again as no-ops. A record keeps the rkey it was given;
19//!   a collection whose lexicon fixes its key (`literal:self`) gets that key; any other gets a TID
20//!   derived from the record itself: its `createdAt` as the time, the record's CID as the clock id.
21//!   A record with neither an rkey nor a `createdAt` is an error: there is nothing to derive it from.
22//! - Each record's CID is the one a PDS computes (`techne_lexicon_check::dag_cbor`), so a builder can
23//!   point one record at another (a strongRef) before either is written.
24//! - **Deletes** ([`plan_with_deletes`]) are placed by the same rule: a record named with a `space`,
25//!   or whose collection is a lexicon type with no public home (or an unknown name in the platform's
26//!   namespace; a name from before a rename is placed as the type it is now), is deleted with the
27//!   platform's private delete
28//!   (`permissioned.deleteRecord`); any other goes into the same `applyWrites` as the public writes.
29//!   They are listed in the order they go out: public first, private last. A key may not be both
30//!   written and deleted, or deleted twice, in one put, and only the writer's own repo is deleted
31//!   from.
32//! - Each write says whether the platform has a lexicon for its type (`lexicon`): one without is
33//!   never curated, so `put` does not wait for a curated version of it.
34
35use techne_lexicon_check::{current_name, dag_cbor, has_public_home, is_record_type, record_key, record_types};
36use serde::{Deserialize, Serialize};
37use serde_json::Value;
38
39/// One record to write: its body (its `$type` is its collection), and optionally its key and space.
40#[derive(Debug, Deserialize)]
41pub struct Record {
42    pub record: Value,
43    #[serde(default)]
44    pub rkey: Option<String>,
45    #[serde(default)]
46    pub space: Option<String>,
47}
48
49/// One write, as it goes out.
50#[derive(Debug, Serialize, PartialEq)]
51pub struct Write {
52    pub uri: String,
53    pub cid: String,
54    pub collection: String,
55    pub rkey: String,
56    pub record: Value,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub space: Option<String>,
59    /// Through the private batch write rather than the PDS.
60    pub private: bool,
61    /// The platform has a lexicon for its type. A record without one (another app's own type, P18.1)
62    /// is never curated: the platform answers it "not curated: no lexicon" (`put` passes that on as
63    /// the record's status), and `put` does not wait for a curated version of it.
64    pub lexicon: bool,
65}
66
67/// One record to delete: its key (`collection` + `rkey`, or its `uri` in the writer's own repo), and
68/// its space when it was placed in one.
69#[derive(Debug, Deserialize)]
70pub struct DeleteRecord {
71    #[serde(default)]
72    pub uri: Option<String>,
73    #[serde(default)]
74    pub collection: Option<String>,
75    #[serde(default)]
76    pub rkey: Option<String>,
77    #[serde(default)]
78    pub space: Option<String>,
79}
80
81/// One delete, as it goes out.
82#[derive(Debug, Serialize, PartialEq)]
83pub struct Delete {
84    pub uri: String,
85    pub collection: String,
86    pub rkey: String,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub space: Option<String>,
89    /// Through the platform's private delete rather than the PDS.
90    pub private: bool,
91}
92
93/// A put with deletes: the writes ([`plan`]'s order) and the deletes in the order they go out: the
94/// public ones first (in the same `applyWrites` as the public writes), then the private ones, each
95/// group in the order given.
96#[derive(Debug, Serialize, PartialEq)]
97pub struct Plan {
98    pub writes: Vec<Write>,
99    pub deletes: Vec<Delete>,
100}
101
102/// [`plan`] for `records`, plus the deletes for `deletes`. A key both written and deleted, or deleted
103/// twice, is an error. Native only: it reads the system clock ([`plan_with_deletes_at`] takes one).
104#[cfg(not(target_arch = "wasm32"))]
105pub fn plan_with_deletes(repo: &str, records: Vec<Record>, deletes: Vec<DeleteRecord>) -> Result<Plan, String> {
106    plan_with_deletes_at(repo, records, deletes, unix_now())
107}
108
109/// [`plan_with_deletes`] as of `now_unix` (the clock [`plan_at`] reads).
110pub fn plan_with_deletes_at(repo: &str, records: Vec<Record>, deletes: Vec<DeleteRecord>, now_unix: u64) -> Result<Plan, String> {
111    let writes = plan_at(repo, records, now_unix)?;
112    let mut deletes = deletes.into_iter().map(|d| delete(repo, d)).collect::<Result<Vec<_>, _>>()?;
113    if let Some(both) = deletes.iter().find(|d| writes.iter().any(|w| w.uri == d.uri)) {
114        return Err(format!("{}: both written and deleted in one put", both.uri));
115    }
116    if let Some((_, twice)) = deletes.iter().enumerate().find(|(i, d)| deletes[..*i].iter().any(|e| e.uri == d.uri)) {
117        return Err(format!("{}: deleted twice in one put", twice.uri));
118    }
119    deletes.sort_by_key(|d| d.private);
120    Ok(Plan { writes, deletes })
121}
122
123/// An NSID's shape, loosely: dotted segments of letters, digits and hyphens, at least three.
124fn is_nsid(s: &str) -> bool {
125    let segments: Vec<&str> = s.split('.').collect();
126    segments.len() >= 3 && segments.iter().all(|g| !g.is_empty() && g.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
127}
128
129/// atproto record-key syntax: 1-512 of `A-Za-z0-9 . _ : ~ -`, not `.` or `..`.
130fn is_rkey(s: &str) -> bool {
131    (1..=512).contains(&s.len())
132        && s != "."
133        && s != ".."
134        && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '~' | '-'))
135}
136
137fn delete(repo: &str, DeleteRecord { uri, collection, rkey, space }: DeleteRecord) -> Result<Delete, String> {
138    let from_uri = |uri: &str| -> Result<(String, String), String> {
139        let rest = uri.strip_prefix("at://").ok_or_else(|| format!("{uri}: not an at:// URI"))?;
140        let mut parts = rest.split('/');
141        match (parts.next(), parts.next(), parts.next(), parts.next()) {
142            (Some(authority), Some(c), Some(k), None) => {
143                if authority != repo {
144                    return Err(format!("{uri}: not in {repo}'s repo; put deletes only the writer's own records (by DID)"));
145                }
146                Ok((c.to_owned(), k.to_owned()))
147            }
148            _ => Err(format!("{uri}: expected at://{{repo}}/{{collection}}/{{rkey}}")),
149        }
150    };
151    let (collection, rkey) = match (uri, collection, rkey) {
152        (Some(uri), None, None) => from_uri(&uri)?,
153        (None, Some(c), Some(k)) => (c, k),
154        // A planned write passed back as a delete names all three; they must agree.
155        (Some(uri), Some(c), Some(k)) => {
156            if from_uri(&uri)? != (c.clone(), k.clone()) {
157                return Err(format!("{uri}: its uri names another key than {c}/{k}"));
158            }
159            (c, k)
160        }
161        _ => return Err("a delete names its uri, or its collection and rkey".to_owned()),
162    };
163    if !is_nsid(&collection) {
164        return Err(format!("{collection}: not a collection NSID"));
165    }
166    if !is_rkey(&rkey) {
167        return Err(format!("{rkey}: not a record key"));
168    }
169    if let Some(space) = &space {
170        if !space.starts_with("at://") {
171            return Err(format!("{space}: a space is an at:// URI"));
172        }
173    }
174    Ok(Delete {
175        uri: format!("at://{repo}/{collection}/{rkey}"),
176        // A name from before a rename (N1) is placed as the type it is now, like a write.
177        private: space.is_some() || (is_ours(&collection) && !has_public_home(current_name(&collection))),
178        collection,
179        rkey,
180        space,
181    })
182}
183
184/// The writes for `records` into `repo`'s own repo: the private ones first, then the public ones,
185/// each group in the order given. Native only: it reads the system clock ([`plan_at`] takes one).
186#[cfg(not(target_arch = "wasm32"))]
187pub fn plan(repo: &str, records: Vec<Record>) -> Result<Vec<Write>, String> {
188    plan_at(repo, records, unix_now())
189}
190
191/// [`plan`] as of `now_unix` (unix seconds): the day decides whether an old name's rename is still
192/// honoured, and so whether the platform still has a lexicon for it (`Write::lexicon`).
193pub fn plan_at(repo: &str, records: Vec<Record>, now_unix: u64) -> Result<Vec<Write>, String> {
194    let mut writes = records.into_iter().map(|r| write(repo, r, now_unix)).collect::<Result<Vec<_>, _>>()?;
195    writes.sort_by_key(|w| !w.private);
196    Ok(writes)
197}
198
199/// The clock [`plan`] reads: the system's. The WebAssembly has none (`SystemTime::now` panics there),
200/// so neither it nor `plan` is built there, and [`crate::call`] refuses `put` without the caller's
201/// `now` (as of 0, every old name would still have its rename, and so a lexicon).
202#[cfg(not(target_arch = "wasm32"))]
203fn unix_now() -> u64 {
204    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
205}
206
207fn write(repo: &str, Record { record, rkey, space }: Record, now_unix: u64) -> Result<Write, String> {
208    let collection = record.get("$type").and_then(Value::as_str).ok_or("a record without a $type")?.to_owned();
209    let cid = dag_cbor::cid_for_json(&record).map_err(|e| format!("{collection}: {}", e.0))?;
210    // A name from before a rename (N1) is placed and keyed as the type it is now; it is written under
211    // the name given.
212    let current = current_name(&collection);
213    let rkey = match (rkey, record_key(current).and_then(|k| k.strip_prefix("literal:"))) {
214        (Some(rkey), _) => rkey,
215        (None, Some(literal)) => literal.to_owned(),
216        (None, None) => {
217            let created = record.get("createdAt").and_then(Value::as_str).ok_or_else(|| {
218                format!("{collection}: no rkey given, and no createdAt to derive one from")
219            })?;
220            let at = chrono::DateTime::parse_from_rfc3339(created).map_err(|e| format!("{collection}: createdAt: {e}"))?;
221            let digest = cid.hash().digest();
222            techne_lexicon_check::tid(at.timestamp_micros(), u16::from_be_bytes([digest[0], digest[1]]))
223        }
224    };
225    Ok(Write {
226        uri: format!("at://{repo}/{collection}/{rkey}"),
227        cid: cid.to_string(),
228        private: space.is_some() || (is_ours(&collection) && !has_public_home(current)),
229        // Dated, as the platform's ingest: an old name is its type's only while its pair is honoured
230        // (`renames::current_name_at`); after, the platform has no lexicon for it and never curates it.
231        lexicon: is_record_type(&collection)
232            || crate::renames::current_name_at(&collection, now_unix).is_some_and(is_record_type),
233        collection,
234        rkey,
235        record,
236        space,
237    })
238}
239
240/// An NSID's namespace: its first two segments (`pub.techne`), lowercased, since an NSID's
241/// authority is a domain and compares without case. `None` for a name with fewer than three segments.
242fn namespace(nsid: &str) -> Option<String> {
243    let mut segments = nsid.splitn(3, '.');
244    let (a, b) = (segments.next()?, segments.next()?);
245    segments.next()?;
246    Some(format!("{a}.{b}").to_ascii_lowercase())
247}
248
249/// The platform's own namespaces, read off the embedded lexicons: those that hold a record type with
250/// no public home (today only `pub.techne`; every other lexicon type here is public), and each
251/// namespace a type there was renamed out of (`coop.lexicon`, N1), which stays the platform's.
252fn private_namespaces() -> &'static [String] {
253    static NAMESPACES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
254    NAMESPACES.get_or_init(|| {
255        let mut out: Vec<String> =
256            record_types().into_iter().filter(|c| !has_public_home(c)).filter_map(namespace).collect();
257        out.sort();
258        out.dedup();
259        let mut old: Vec<String> = crate::renames::pairs()
260            .iter()
261            .filter(|p| namespace(&p.new).is_some_and(|ns| out.contains(&ns)))
262            .filter_map(|p| namespace(&p.old))
263            .collect();
264        out.append(&mut old);
265        out.sort();
266        out.dedup();
267        out
268    })
269}
270
271/// Whether `collection` is the platform's to place (P18.1 guard, main's pick 2026-10-24, decided by
272/// lucian, relayed by main 2026-10-26): a lexicon type under its current name or one from before a
273/// rename (N1), or any other name in a [`private_namespaces`] namespace. Such a name is a mistake (a
274/// typo after the namespace, other letter case), so it stays private rather than reach the PDS.
275fn is_ours(collection: &str) -> bool {
276    // A community draft (`registry.publishType`, `pub.techne.draft.c.<id>.<name>`) sits under
277    // `pub.techne` but is not the platform's to place: like another app's own type, its records are the
278    // user's public records (private only with a `space`). The pinned check's rule decides it, so a name
279    // that only looks like one (a malformed id, other letter case) stays private like any other typo.
280    if techne_lexicon_check::runtime::community::is_community(collection) {
281        return false;
282    }
283    is_record_type(current_name(collection)) || namespace(collection).is_some_and(|ns| private_namespaces().contains(&ns))
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use serde_json::json;
290
291    /// The platform's NSID rename map ([`crate::renames`]: the crate's copy of the pinned input).
292    const RENAMES_JSON: &str = crate::renames::JSON;
293
294    const AT: &str = "2026-10-19T12:00:00.000Z";
295    /// The clock the tests plan at: `AT` in unix seconds, inside every rename's window, so a test does
296    /// not turn red on the day a pair closes (the 64 pre-N1 pairs close after 2027-04-09).
297    const NOW: u64 = 1_792_411_200;
298
299    fn one(collection: &str, space: Option<&str>) -> Write {
300        let record = json!({"$type": collection, "text": "Honest walk", "createdAt": AT});
301        plan_at("did:plc:alice", vec![Record { record, rkey: None, space: space.map(str::to_owned) }], NOW).unwrap().remove(0)
302    }
303
304    /// A lexicon type with no public home, and one with a public home in the same namespace.
305    fn private_and_public_types() -> (&'static str, &'static str) {
306        let private = record_types().into_iter().find(|c| !has_public_home(c)).expect("a private type");
307        let ns = namespace(private);
308        let public = record_types().into_iter().find(|c| has_public_home(c) && namespace(c) == ns).expect("a public type");
309        (private, public)
310    }
311
312    #[test]
313    fn a_community_draft_is_public_unless_a_space_is_named() {
314        let c = "pub.techne.draft.c.abcdefghi7.toolListing";
315        assert!(!one(c, None).private, "{c} goes to the user's repo");
316        assert!(one(c, Some("at://did:plc:alice/pub.techne.draft.space.private/k")).private, "{c} in a space");
317        // Still the platform's: a typo elsewhere in its namespace, and the bare prefix.
318        assert!(one("pub.techne.draft.notAType", None).private);
319        assert!(one("pub.techne.draft.cx.thing", None).private);
320    }
321
322    #[test]
323    fn a_name_that_only_looks_like_a_community_draft_stays_private() {
324        // The registry never mints these (community::is_id: 6-26 lower-case base32 from a letter, then a
325        // name), so each is a typo under the platform's namespace, not someone's published type.
326        for c in [
327            "pub.techne.draft.c.r8fd.toolListing",        // id too short, and 8 is not base32
328            "pub.techne.draft.c.7bcdefghi.toolListing",   // id starts with a digit
329            "Pub.Techne.Draft.C.abcdefghi7.toolListing", // other letter case
330            "pub.techne.draft.c.ABCDEFGHI7.toolListing", // upper-case id
331            "pub.techne.draft.c.abcdefghi7",             // no name
332        ] {
333            assert!(!techne_lexicon_check::runtime::community::is_community(c), "{c}");
334            assert!(one(c, None).private, "{c} stays private");
335        }
336    }
337
338    #[test]
339    fn a_record_without_a_type_or_anything_to_key_it_by_is_refused() {
340        let repo = "did:plc:alice";
341        assert!(plan(repo, vec![Record { record: json!({"name": "x"}), rkey: None, space: None }]).is_err());
342        let bare = json!({"$type": "x.example.thing", "name": "x"});
343        let err = plan(repo, vec![Record { record: bare.clone(), rkey: None, space: None }]).unwrap_err();
344        assert!(err.contains("no createdAt"), "{err}");
345        assert_eq!(plan(repo, vec![Record { record: bare, rkey: Some("k".into()), space: None }]).unwrap()[0].rkey, "k");
346    }
347
348    fn del(uri: Option<&str>, collection: Option<&str>, rkey: Option<&str>, space: Option<&str>) -> DeleteRecord {
349        let s = |v: Option<&str>| v.map(str::to_owned);
350        DeleteRecord { uri: s(uri), collection: s(collection), rkey: s(rkey), space: s(space) }
351    }
352
353    #[test]
354    fn deletes_are_placed_like_writes_in_the_order_they_go_out() {
355        let repo = "did:plc:alice";
356        let event = "community.lexicon.calendar.event";
357        let space = "at://did:plc:alice/pub.techne.draft.space.event.detail/k1";
358        let p = plan_with_deletes(
359            repo,
360            vec![],
361            vec![
362                del(Some(&format!("at://{repo}/{event}/k1")), None, None, None),
363                del(None, Some("pub.techne.draft.event.detail"), Some("k1"), Some(space)),
364                del(None, Some("com.example.thirdparty.note"), Some("k2"), None),
365            ],
366        )
367        .unwrap();
368        assert!(p.writes.is_empty());
369        assert_eq!(
370            p.deletes.iter().map(|d| (d.collection.as_str(), d.private)).collect::<Vec<_>>(),
371            [(event, false), ("com.example.thirdparty.note", false), ("pub.techne.draft.event.detail", true)]
372        );
373        assert_eq!(p.deletes[0].uri, format!("at://{repo}/{event}/k1"));
374        assert_eq!(p.deletes[2].space.as_deref(), Some(space));
375    }
376
377    #[test]
378    fn a_delete_outside_the_repo_or_of_a_key_also_written_or_without_a_key_is_refused() {
379        let repo = "did:plc:alice";
380        let event = json!({"$type": "community.lexicon.calendar.event", "name": "x", "startsAt": "2026-10-25T18:00:00.000Z", "createdAt": "2026-10-19T12:00:00.000Z"});
381        let err = plan_with_deletes(repo, vec![], vec![del(Some("at://did:plc:bob/community.lexicon.calendar.event/k"), None, None, None)]).unwrap_err();
382        assert!(err.contains("not in did:plc:alice's repo"), "{err}");
383        let both = plan_with_deletes(
384            repo,
385            vec![Record { record: event, rkey: Some("k".into()), space: None }],
386            vec![del(None, Some("community.lexicon.calendar.event"), Some("k"), None)],
387        )
388        .unwrap_err();
389        assert!(both.contains("both written and deleted"), "{both}");
390        assert!(plan_with_deletes(repo, vec![], vec![del(None, Some("x.y.z"), None, None)]).is_err());
391        assert!(plan_with_deletes(repo, vec![], vec![del(Some("at://did:plc:alice/x.y.z"), None, None, None)]).is_err());
392        let ev = "community.lexicon.calendar.event";
393        let twice = plan_with_deletes(repo, vec![], vec![del(None, Some(ev), Some("k"), None), del(Some(&format!("at://{repo}/{ev}/k")), None, None, Some("at://did:plc:alice/x.y.space/k"))]).unwrap_err();
394        assert!(twice.contains("deleted twice"), "{twice}");
395        for (uri, c, k, space) in [
396            (Some("at://did:plc:alice/x.y.z/k?q"), None, None, None),
397            (Some("at://did:plc:alice/x.y.z/k#f"), None, None, None),
398            (None, Some("x.y/z"), Some("k"), None),
399            (None, Some("x.y.z"), Some(".."), None),
400            (None, Some("x.y.z"), Some("k"), Some("")),
401            (Some("at://alice.test/x.y.z/k"), None, None, None),
402            (Some("at://did:plc:alice/x.y.z/other"), Some("x.y.z"), Some("k"), None),
403        ] {
404            assert!(plan_with_deletes(repo, vec![], vec![del(uri, c, k, space)]).is_err(), "{uri:?} {c:?} {k:?} {space:?}");
405        }
406        // A planned write passed back: uri, collection and rkey that agree.
407        let back = plan_with_deletes(repo, vec![], vec![del(Some(&format!("at://{repo}/{ev}/k")), Some(ev), Some("k"), None)]).unwrap();
408        assert_eq!(back.deletes[0].uri, format!("at://{repo}/{ev}/k"));
409    }
410
411    /// P18.1 (lucian 2026-10-24): a type with no lexicon goes to the user's PDS like any public record;
412    /// a lexicon type with no public home, or any record in a space, stays private.
413    #[test]
414    fn a_type_with_no_lexicon_goes_to_the_users_pds() {
415        assert!(!one("com.example.thirdparty.note", None).private, "no lexicon: the user's PDS");
416        let space = "at://did:plc:alice/com.example.space/self";
417        assert!(one("com.example.thirdparty.note", Some(space)).private, "a record in a space is private whatever its type");
418        let (private, public) = private_and_public_types();
419        assert!(one(private, None).private, "{private}: a lexicon type with no public home stays private");
420        assert!(!one(public, None).private, "{public}: a lexicon type with a public home is public");
421    }
422
423    /// P18.1 lows: the plan says whether the platform has a lexicon for a record's type, so `put`
424    /// does not wait for a curated version of one it never curates. An unknown name in the platform's
425    /// own namespace has none either, though it stays private.
426    #[test]
427    fn the_plan_says_whether_the_platform_has_a_lexicon_for_the_type() {
428        let (private, public) = private_and_public_types();
429        assert!(one(private, None).lexicon && one(public, None).lexicon);
430        assert!(!one("com.example.thirdparty.note", None).lexicon, "another app's own type");
431        assert!(!one(&format!("{private}x"), None).lexicon, "a typo in the platform's namespace");
432    }
433
434    /// P18.1 guard (lucian's decision, relayed by main 2026-10-26): an unknown name inside the platform's own
435    /// namespace (a typo, a name from before a rename, other letter case) stays private, so it never
436    /// lands on the user's PDS; only other namespaces with no lexicon go there.
437    #[test]
438    fn an_unknown_type_in_the_platforms_namespace_stays_private() {
439        let (private, public) = private_and_public_types();
440        let ns = namespace(private).unwrap();
441        let old_name = |c: &str| c.replacen(".draft.", ".", 1);
442        let unknown = [
443            format!("{private}x"),
444            old_name(private),
445            old_name(public),
446            private.to_ascii_uppercase(),
447            public.to_ascii_uppercase(),
448        ];
449        for collection in &unknown {
450            assert!(!is_record_type(collection), "{collection} has no lexicon");
451            assert!(one(collection, None).private, "{collection}: an unknown name in {ns} stays private");
452        }
453        let neighbour = format!("{ns}x.note");
454        assert!(!one(&neighbour, None).private, "{neighbour}: only the namespace itself");
455    }
456
457
458    /// The rename map's pairs (the crate's copy), old -> new.
459    fn renames() -> Vec<(String, String)> {
460        crate::renames::pairs().iter().map(|p| (p.old.clone(), p.new.clone())).collect()
461    }
462
463    /// The crate's copy of the rename map is the platform's (the pinned input, byte for byte) and the
464    /// check's, both ways: every pair is what `current_name` answers, and every name the check renames is
465    /// a pair here. The check exposes no list, so the second direction asks it about every name a pair
466    /// could have: each old name here, and each current type and renamed-to name under the spellings N1
467    /// and the draft move renamed out of (`coop.lexicon.draft.X`, `coop.lexicon.X`).
468    #[test]
469    fn renames_copy_is_the_platforms() {
470        let input = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../inputs/platform-api/nsid_renames.json");
471        if let Ok(bytes) = std::fs::read_to_string(&input) {
472            assert_eq!(bytes, RENAMES_JSON, "crates/techne-sdk/src/nsid_renames.json differs from {}: run scripts/inputs-sync.mjs", input.display());
473        }
474        let pairs = renames();
475        assert!(!pairs.is_empty());
476        for (old, new) in &pairs {
477            assert_eq!(current_name(old), new, "the check renames {old} otherwise");
478        }
479        let map: std::collections::BTreeMap<&str, &str> = pairs.iter().map(|(o, n)| (o.as_str(), n.as_str())).collect();
480        let currents: std::collections::BTreeSet<String> =
481            record_types().into_iter().map(str::to_owned).chain(pairs.iter().map(|(_, n)| n.clone())).collect();
482        let mut asked = 0;
483        for current in &currents {
484            let tail = current.strip_prefix("pub.techne.draft.").or_else(|| current.strip_prefix("social.scenius."));
485            let candidates = tail
486                .into_iter()
487                .flat_map(|t| [format!("coop.lexicon.draft.{t}"), format!("coop.lexicon.{t}"), format!("social.scenius.{t}")]);
488            for name in candidates.chain(std::iter::once(current.clone())) {
489                asked += 1;
490                let answered = current_name(&name);
491                if answered != name {
492                    assert_eq!(map.get(name.as_str()).copied(), Some(answered), "the check renames {name} to {answered}; this copy does not");
493                }
494            }
495        }
496        assert!(asked > pairs.len(), "the reverse direction asked about {asked} names");
497    }
498
499    /// `lexicon` is dated as the platform's ingest is: an old name counts as its type through its pair's
500    /// last day and not after it (`closeEarlier`'s pairs on their own earlier day), a current name always.
501    #[test]
502    fn an_old_name_has_a_lexicon_only_while_its_rename_is_honoured() {
503        let doc: Value = serde_json::from_str(RENAMES_JSON).unwrap();
504        let day = |d: &str| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap();
505        let at = |d: chrono::NaiveDate, h, m, s| u64::try_from(d.and_hms_opt(h, m, s).unwrap().and_utc().timestamp()).unwrap();
506        let early: Vec<(chrono::NaiveDate, String)> = doc["closeEarlier"]
507            .as_object()
508            .unwrap()
509            .iter()
510            .flat_map(|(d, olds)| olds.as_array().unwrap().iter().map(move |o| (day(d), o.as_str().unwrap().to_owned())))
511            .collect();
512        let last_of = |old: &str| early.iter().find(|(_, o)| o == old).map_or_else(|| day(doc["deleteAfter"].as_str().unwrap()), |(d, _)| *d);
513        let (mut early_seen, mut late_seen) = (false, false);
514        for (old, new) in renames() {
515            if !is_record_type(&new) || is_record_type(&old) {
516                continue;
517            }
518            let last = last_of(&old);
519            early_seen |= last != day(doc["deleteAfter"].as_str().unwrap());
520            late_seen |= last == day(doc["deleteAfter"].as_str().unwrap());
521            let lexicon = |now| {
522                let record = json!({"$type": old, "text": "Honest walk", "createdAt": AT});
523                plan_at("did:plc:alice", vec![Record { record, rkey: Some("k".into()), space: None }], now).unwrap()[0].lexicon
524            };
525            assert!(lexicon(at(last, 23, 59, 59)), "{old}: a lexicon through {last}");
526            assert!(!lexicon(at(last + chrono::Days::new(1), 0, 0, 0)), "{old}: no lexicon after {last}");
527            let record = json!({"$type": new, "text": "Honest walk", "createdAt": AT});
528            assert!(plan_at("did:plc:alice", vec![Record { record, rkey: Some("k".into()), space: None }], u64::MAX / 2).unwrap()[0].lexicon);
529        }
530        assert!(early_seen && late_seen, "both kinds of window are checked");
531    }
532
533    /// N1: a name from before the rename is a type the platform still has, under its current name. An
534    /// old name of a public type is public (an old build's record goes to the user's PDS, and the
535    /// platform curates it through the mapping), one of a private type private; either keeps the name
536    /// it was given and its type's key.
537    #[test]
538    fn an_old_name_is_placed_as_the_type_it_was_renamed_to() {
539        let (mut public, mut private, mut literal) = (0, 0, 0);
540        for (old, _) in renames() {
541            let current = current_name(&old);
542            if old == current || !is_record_type(current) {
543                continue;
544            }
545            let w = one(&old, None);
546            assert_eq!(w.private, !has_public_home(current), "{old} -> {current}");
547            assert!(w.lexicon, "{old}: the platform has {current}");
548            assert_eq!(w.collection, old, "written under the name given");
549            if let Some(key) = record_key(current).and_then(|k| k.strip_prefix("literal:")) {
550                assert_eq!(w.rkey, key, "{old}: {current}'s key");
551                literal += 1;
552            }
553            if w.private { private += 1 } else { public += 1 }
554        }
555        assert!(public > 0 && private > 0 && literal > 0, "public {public}, private {private}, literal {literal}");
556    }
557
558    /// N1: a delete under a name from before the rename is placed as the type it is now, like a write:
559    /// an old name of a public type goes in the public `applyWrites`, one of a private type through
560    /// the private delete; the delete keeps the name given.
561    #[test]
562    fn a_delete_under_an_old_name_is_placed_as_the_type_it_was_renamed_to() {
563        let repo = "did:plc:alice";
564        let (mut public, mut private) = (0, 0);
565        for (old, _) in renames() {
566            let current = current_name(&old);
567            if old == current || !is_record_type(current) {
568                continue;
569            }
570            let p = plan_with_deletes(repo, vec![], vec![del(None, Some(&old), Some("k1"), None)]).unwrap();
571            let d = &p.deletes[0];
572            assert_eq!(d.private, !has_public_home(current), "{old} -> {current}");
573            assert_eq!((d.collection.as_str(), d.uri.clone()), (old.as_str(), format!("at://{repo}/{old}/k1")), "deleted under the name given");
574            if d.private { private += 1 } else { public += 1 }
575        }
576        assert!(public > 0 && private > 0, "public {public}, private {private}");
577    }
578
579    /// N1: the namespace a type was renamed out of is still the platform's: an unknown name there (a
580    /// typo, other letter case) stays private, like one in the current namespace.
581    #[test]
582    fn an_unknown_name_in_the_old_namespace_stays_private() {
583        let renames = renames();
584        let old = |public: bool| {
585            renames
586                .iter()
587                .find(|(o, n)| o != n && is_record_type(n) && has_public_home(n) == public && namespace(o) != namespace(n))
588                .map(|(o, _)| o.clone())
589                .expect("a renamed type")
590        };
591        for old in [old(true), old(false)] {
592            for collection in [format!("{old}x"), old.to_ascii_uppercase()] {
593                assert!(one(&collection, None).private, "{collection}: an unknown name in {:?}", namespace(&old));
594                let p = plan_with_deletes("did:plc:alice", vec![], vec![del(None, Some(&collection), Some("k1"), None)]).unwrap();
595                assert!(p.deletes[0].private, "{collection}: its delete is private too");
596            }
597        }
598    }
599}