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    is_record_type(current_name(collection)) || namespace(collection).is_some_and(|ns| private_namespaces().contains(&ns))
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use serde_json::json;
283
284    /// The platform's NSID rename map ([`crate::renames`]: the crate's copy of the pinned input).
285    const RENAMES_JSON: &str = crate::renames::JSON;
286
287    const AT: &str = "2026-10-19T12:00:00.000Z";
288    /// The clock the tests plan at: `AT` in unix seconds, inside every rename's window, so a test does
289    /// not turn red on the day a pair closes (the 64 pre-N1 pairs close after 2027-04-09).
290    const NOW: u64 = 1_792_411_200;
291
292    fn one(collection: &str, space: Option<&str>) -> Write {
293        let record = json!({"$type": collection, "text": "Honest walk", "createdAt": AT});
294        plan_at("did:plc:alice", vec![Record { record, rkey: None, space: space.map(str::to_owned) }], NOW).unwrap().remove(0)
295    }
296
297    /// A lexicon type with no public home, and one with a public home in the same namespace.
298    fn private_and_public_types() -> (&'static str, &'static str) {
299        let private = record_types().into_iter().find(|c| !has_public_home(c)).expect("a private type");
300        let ns = namespace(private);
301        let public = record_types().into_iter().find(|c| has_public_home(c) && namespace(c) == ns).expect("a public type");
302        (private, public)
303    }
304
305    #[test]
306    fn a_record_without_a_type_or_anything_to_key_it_by_is_refused() {
307        let repo = "did:plc:alice";
308        assert!(plan(repo, vec![Record { record: json!({"name": "x"}), rkey: None, space: None }]).is_err());
309        let bare = json!({"$type": "x.example.thing", "name": "x"});
310        let err = plan(repo, vec![Record { record: bare.clone(), rkey: None, space: None }]).unwrap_err();
311        assert!(err.contains("no createdAt"), "{err}");
312        assert_eq!(plan(repo, vec![Record { record: bare, rkey: Some("k".into()), space: None }]).unwrap()[0].rkey, "k");
313    }
314
315    fn del(uri: Option<&str>, collection: Option<&str>, rkey: Option<&str>, space: Option<&str>) -> DeleteRecord {
316        let s = |v: Option<&str>| v.map(str::to_owned);
317        DeleteRecord { uri: s(uri), collection: s(collection), rkey: s(rkey), space: s(space) }
318    }
319
320    #[test]
321    fn deletes_are_placed_like_writes_in_the_order_they_go_out() {
322        let repo = "did:plc:alice";
323        let event = "community.lexicon.calendar.event";
324        let space = "at://did:plc:alice/pub.techne.draft.space.event.detail/k1";
325        let p = plan_with_deletes(
326            repo,
327            vec![],
328            vec![
329                del(Some(&format!("at://{repo}/{event}/k1")), None, None, None),
330                del(None, Some("pub.techne.draft.event.detail"), Some("k1"), Some(space)),
331                del(None, Some("com.example.thirdparty.note"), Some("k2"), None),
332            ],
333        )
334        .unwrap();
335        assert!(p.writes.is_empty());
336        assert_eq!(
337            p.deletes.iter().map(|d| (d.collection.as_str(), d.private)).collect::<Vec<_>>(),
338            [(event, false), ("com.example.thirdparty.note", false), ("pub.techne.draft.event.detail", true)]
339        );
340        assert_eq!(p.deletes[0].uri, format!("at://{repo}/{event}/k1"));
341        assert_eq!(p.deletes[2].space.as_deref(), Some(space));
342    }
343
344    #[test]
345    fn a_delete_outside_the_repo_or_of_a_key_also_written_or_without_a_key_is_refused() {
346        let repo = "did:plc:alice";
347        let event = json!({"$type": "community.lexicon.calendar.event", "name": "x", "startsAt": "2026-10-25T18:00:00.000Z", "createdAt": "2026-10-19T12:00:00.000Z"});
348        let err = plan_with_deletes(repo, vec![], vec![del(Some("at://did:plc:bob/community.lexicon.calendar.event/k"), None, None, None)]).unwrap_err();
349        assert!(err.contains("not in did:plc:alice's repo"), "{err}");
350        let both = plan_with_deletes(
351            repo,
352            vec![Record { record: event, rkey: Some("k".into()), space: None }],
353            vec![del(None, Some("community.lexicon.calendar.event"), Some("k"), None)],
354        )
355        .unwrap_err();
356        assert!(both.contains("both written and deleted"), "{both}");
357        assert!(plan_with_deletes(repo, vec![], vec![del(None, Some("x.y.z"), None, None)]).is_err());
358        assert!(plan_with_deletes(repo, vec![], vec![del(Some("at://did:plc:alice/x.y.z"), None, None, None)]).is_err());
359        let ev = "community.lexicon.calendar.event";
360        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();
361        assert!(twice.contains("deleted twice"), "{twice}");
362        for (uri, c, k, space) in [
363            (Some("at://did:plc:alice/x.y.z/k?q"), None, None, None),
364            (Some("at://did:plc:alice/x.y.z/k#f"), None, None, None),
365            (None, Some("x.y/z"), Some("k"), None),
366            (None, Some("x.y.z"), Some(".."), None),
367            (None, Some("x.y.z"), Some("k"), Some("")),
368            (Some("at://alice.test/x.y.z/k"), None, None, None),
369            (Some("at://did:plc:alice/x.y.z/other"), Some("x.y.z"), Some("k"), None),
370        ] {
371            assert!(plan_with_deletes(repo, vec![], vec![del(uri, c, k, space)]).is_err(), "{uri:?} {c:?} {k:?} {space:?}");
372        }
373        // A planned write passed back: uri, collection and rkey that agree.
374        let back = plan_with_deletes(repo, vec![], vec![del(Some(&format!("at://{repo}/{ev}/k")), Some(ev), Some("k"), None)]).unwrap();
375        assert_eq!(back.deletes[0].uri, format!("at://{repo}/{ev}/k"));
376    }
377
378    /// P18.1 (lucian 2026-10-24): a type with no lexicon goes to the user's PDS like any public record;
379    /// a lexicon type with no public home, or any record in a space, stays private.
380    #[test]
381    fn a_type_with_no_lexicon_goes_to_the_users_pds() {
382        assert!(!one("com.example.thirdparty.note", None).private, "no lexicon: the user's PDS");
383        let space = "at://did:plc:alice/com.example.space/self";
384        assert!(one("com.example.thirdparty.note", Some(space)).private, "a record in a space is private whatever its type");
385        let (private, public) = private_and_public_types();
386        assert!(one(private, None).private, "{private}: a lexicon type with no public home stays private");
387        assert!(!one(public, None).private, "{public}: a lexicon type with a public home is public");
388    }
389
390    /// P18.1 lows: the plan says whether the platform has a lexicon for a record's type, so `put`
391    /// does not wait for a curated version of one it never curates. An unknown name in the platform's
392    /// own namespace has none either, though it stays private.
393    #[test]
394    fn the_plan_says_whether_the_platform_has_a_lexicon_for_the_type() {
395        let (private, public) = private_and_public_types();
396        assert!(one(private, None).lexicon && one(public, None).lexicon);
397        assert!(!one("com.example.thirdparty.note", None).lexicon, "another app's own type");
398        assert!(!one(&format!("{private}x"), None).lexicon, "a typo in the platform's namespace");
399    }
400
401    /// P18.1 guard (lucian's decision, relayed by main 2026-10-26): an unknown name inside the platform's own
402    /// namespace (a typo, a name from before a rename, other letter case) stays private, so it never
403    /// lands on the user's PDS; only other namespaces with no lexicon go there.
404    #[test]
405    fn an_unknown_type_in_the_platforms_namespace_stays_private() {
406        let (private, public) = private_and_public_types();
407        let ns = namespace(private).unwrap();
408        let old_name = |c: &str| c.replacen(".draft.", ".", 1);
409        let unknown = [
410            format!("{private}x"),
411            old_name(private),
412            old_name(public),
413            private.to_ascii_uppercase(),
414            public.to_ascii_uppercase(),
415        ];
416        for collection in &unknown {
417            assert!(!is_record_type(collection), "{collection} has no lexicon");
418            assert!(one(collection, None).private, "{collection}: an unknown name in {ns} stays private");
419        }
420        let neighbour = format!("{ns}x.note");
421        assert!(!one(&neighbour, None).private, "{neighbour}: only the namespace itself");
422    }
423
424
425    /// The rename map's pairs (the crate's copy), old -> new.
426    fn renames() -> Vec<(String, String)> {
427        crate::renames::pairs().iter().map(|p| (p.old.clone(), p.new.clone())).collect()
428    }
429
430    /// The crate's copy of the rename map is the platform's (the pinned input, byte for byte) and the
431    /// check's, both ways: every pair is what `current_name` answers, and every name the check renames is
432    /// a pair here. The check exposes no list, so the second direction asks it about every name a pair
433    /// could have: each old name here, and each current type and renamed-to name under the spellings N1
434    /// and the draft move renamed out of (`coop.lexicon.draft.X`, `coop.lexicon.X`).
435    #[test]
436    fn renames_copy_is_the_platforms() {
437        let input = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../inputs/platform-api/nsid_renames.json");
438        if let Ok(bytes) = std::fs::read_to_string(&input) {
439            assert_eq!(bytes, RENAMES_JSON, "crates/techne-sdk/src/nsid_renames.json differs from {}: run scripts/inputs-sync.mjs", input.display());
440        }
441        let pairs = renames();
442        assert!(!pairs.is_empty());
443        for (old, new) in &pairs {
444            assert_eq!(current_name(old), new, "the check renames {old} otherwise");
445        }
446        let map: std::collections::BTreeMap<&str, &str> = pairs.iter().map(|(o, n)| (o.as_str(), n.as_str())).collect();
447        let currents: std::collections::BTreeSet<String> =
448            record_types().into_iter().map(str::to_owned).chain(pairs.iter().map(|(_, n)| n.clone())).collect();
449        let mut asked = 0;
450        for current in &currents {
451            let tail = current.strip_prefix("pub.techne.draft.").or_else(|| current.strip_prefix("social.scenius."));
452            let candidates = tail
453                .into_iter()
454                .flat_map(|t| [format!("coop.lexicon.draft.{t}"), format!("coop.lexicon.{t}"), format!("social.scenius.{t}")]);
455            for name in candidates.chain(std::iter::once(current.clone())) {
456                asked += 1;
457                let answered = current_name(&name);
458                if answered != name {
459                    assert_eq!(map.get(name.as_str()).copied(), Some(answered), "the check renames {name} to {answered}; this copy does not");
460                }
461            }
462        }
463        assert!(asked > pairs.len(), "the reverse direction asked about {asked} names");
464    }
465
466    /// `lexicon` is dated as the platform's ingest is: an old name counts as its type through its pair's
467    /// last day and not after it (`closeEarlier`'s pairs on their own earlier day), a current name always.
468    #[test]
469    fn an_old_name_has_a_lexicon_only_while_its_rename_is_honoured() {
470        let doc: Value = serde_json::from_str(RENAMES_JSON).unwrap();
471        let day = |d: &str| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap();
472        let at = |d: chrono::NaiveDate, h, m, s| u64::try_from(d.and_hms_opt(h, m, s).unwrap().and_utc().timestamp()).unwrap();
473        let early: Vec<(chrono::NaiveDate, String)> = doc["closeEarlier"]
474            .as_object()
475            .unwrap()
476            .iter()
477            .flat_map(|(d, olds)| olds.as_array().unwrap().iter().map(move |o| (day(d), o.as_str().unwrap().to_owned())))
478            .collect();
479        let last_of = |old: &str| early.iter().find(|(_, o)| o == old).map_or_else(|| day(doc["deleteAfter"].as_str().unwrap()), |(d, _)| *d);
480        let (mut early_seen, mut late_seen) = (false, false);
481        for (old, new) in renames() {
482            if !is_record_type(&new) || is_record_type(&old) {
483                continue;
484            }
485            let last = last_of(&old);
486            early_seen |= last != day(doc["deleteAfter"].as_str().unwrap());
487            late_seen |= last == day(doc["deleteAfter"].as_str().unwrap());
488            let lexicon = |now| {
489                let record = json!({"$type": old, "text": "Honest walk", "createdAt": AT});
490                plan_at("did:plc:alice", vec![Record { record, rkey: Some("k".into()), space: None }], now).unwrap()[0].lexicon
491            };
492            assert!(lexicon(at(last, 23, 59, 59)), "{old}: a lexicon through {last}");
493            assert!(!lexicon(at(last + chrono::Days::new(1), 0, 0, 0)), "{old}: no lexicon after {last}");
494            let record = json!({"$type": new, "text": "Honest walk", "createdAt": AT});
495            assert!(plan_at("did:plc:alice", vec![Record { record, rkey: Some("k".into()), space: None }], u64::MAX / 2).unwrap()[0].lexicon);
496        }
497        assert!(early_seen && late_seen, "both kinds of window are checked");
498    }
499
500    /// N1: a name from before the rename is a type the platform still has, under its current name. An
501    /// old name of a public type is public (an old build's record goes to the user's PDS, and the
502    /// platform curates it through the mapping), one of a private type private; either keeps the name
503    /// it was given and its type's key.
504    #[test]
505    fn an_old_name_is_placed_as_the_type_it_was_renamed_to() {
506        let (mut public, mut private, mut literal) = (0, 0, 0);
507        for (old, _) in renames() {
508            let current = current_name(&old);
509            if old == current || !is_record_type(current) {
510                continue;
511            }
512            let w = one(&old, None);
513            assert_eq!(w.private, !has_public_home(current), "{old} -> {current}");
514            assert!(w.lexicon, "{old}: the platform has {current}");
515            assert_eq!(w.collection, old, "written under the name given");
516            if let Some(key) = record_key(current).and_then(|k| k.strip_prefix("literal:")) {
517                assert_eq!(w.rkey, key, "{old}: {current}'s key");
518                literal += 1;
519            }
520            if w.private { private += 1 } else { public += 1 }
521        }
522        assert!(public > 0 && private > 0 && literal > 0, "public {public}, private {private}, literal {literal}");
523    }
524
525    /// N1: a delete under a name from before the rename is placed as the type it is now, like a write:
526    /// an old name of a public type goes in the public `applyWrites`, one of a private type through
527    /// the private delete; the delete keeps the name given.
528    #[test]
529    fn a_delete_under_an_old_name_is_placed_as_the_type_it_was_renamed_to() {
530        let repo = "did:plc:alice";
531        let (mut public, mut private) = (0, 0);
532        for (old, _) in renames() {
533            let current = current_name(&old);
534            if old == current || !is_record_type(current) {
535                continue;
536            }
537            let p = plan_with_deletes(repo, vec![], vec![del(None, Some(&old), Some("k1"), None)]).unwrap();
538            let d = &p.deletes[0];
539            assert_eq!(d.private, !has_public_home(current), "{old} -> {current}");
540            assert_eq!((d.collection.as_str(), d.uri.clone()), (old.as_str(), format!("at://{repo}/{old}/k1")), "deleted under the name given");
541            if d.private { private += 1 } else { public += 1 }
542        }
543        assert!(public > 0 && private > 0, "public {public}, private {private}");
544    }
545
546    /// N1: the namespace a type was renamed out of is still the platform's: an unknown name there (a
547    /// typo, other letter case) stays private, like one in the current namespace.
548    #[test]
549    fn an_unknown_name_in_the_old_namespace_stays_private() {
550        let renames = renames();
551        let old = |public: bool| {
552            renames
553                .iter()
554                .find(|(o, n)| o != n && is_record_type(n) && has_public_home(n) == public && namespace(o) != namespace(n))
555                .map(|(o, _)| o.clone())
556                .expect("a renamed type")
557        };
558        for old in [old(true), old(false)] {
559            for collection in [format!("{old}x"), old.to_ascii_uppercase()] {
560                assert!(one(&collection, None).private, "{collection}: an unknown name in {:?}", namespace(&old));
561                let p = plan_with_deletes("did:plc:alice", vec![], vec![del(None, Some(&collection), Some("k1"), None)]).unwrap();
562                assert!(p.deletes[0].private, "{collection}: its delete is private too");
563            }
564        }
565    }
566}