techne-sdk 0.3.8

TechneSDK's logic written once: version ranking (eventState) and offline record verification over the published check, shipped to TypeScript as WebAssembly; with opt-in atproto plumbing (service auth, DID resolution, signed labels, proven record fetches, PDS password sessions) behind the `crypto` and `net` features.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
//! `put`'s plan (techne P13.2): the records an app writes, keyed, addressed and in the order they go
//! out. The TypeScript `put` (`sdk/src/put`) sends what this answers; nothing here does I/O.
//!
//! - **Private first, public last.** A record is private when it names a `space`, or when its
//!   collection has no public home (the placement manifest's `public` list) and is either a lexicon
//!   type or any other name in the platform's own namespaces (`pub.techne`, and `coop.lexicon` it
//!   was renamed out of: a typo after the namespace, other letter case; a typo in the namespace
//!   itself is not caught): it goes through the
//!   platform's private batch write (`permissioned.putRecords`). Every other record goes to the
//!   user's PDS in one `applyWrites`, which is atomic in one repo. That includes a type in another
//!   namespace the platform has no lexicon for (another app's own record, P18.1, lucian 2026-10-24):
//!   it is the user's record in the user's repo, and the platform answers it "not curated: no
//!   lexicon". A name from before a rename (N1) is placed, keyed and curated as the type it is now,
//!   and written under the name given: an old build's public record stays public. Public and private
//!   writes cannot share a transaction, so the private ones land first
//!   and the public ones last: a reader that sees the public record finds what it points to.
//! - **Deterministic rkeys**, so a put sent again after a failure lands on the same keys and the
//!   records that already landed are written again as no-ops. A record keeps the rkey it was given;
//!   a collection whose lexicon fixes its key (`literal:self`) gets that key; any other gets a TID
//!   derived from the record itself: its `createdAt` as the time, the record's CID as the clock id.
//!   A record with neither an rkey nor a `createdAt` is an error: there is nothing to derive it from.
//! - Each record's CID is the one a PDS computes (`techne_lexicon_check::dag_cbor`), so a builder can
//!   point one record at another (a strongRef) before either is written.
//! - **Deletes** ([`plan_with_deletes`]) are placed by the same rule: a record named with a `space`,
//!   or whose collection is a lexicon type with no public home (or an unknown name in the platform's
//!   namespace; a name from before a rename is placed as the type it is now), is deleted with the
//!   platform's private delete
//!   (`permissioned.deleteRecord`); any other goes into the same `applyWrites` as the public writes.
//!   They are listed in the order they go out: public first, private last. A key may not be both
//!   written and deleted, or deleted twice, in one put, and only the writer's own repo is deleted
//!   from.
//! - Each write says whether the platform has a lexicon for its type (`lexicon`): one without is
//!   never curated, so `put` does not wait for a curated version of it.

use techne_lexicon_check::{current_name, dag_cbor, has_public_home, is_record_type, record_key, record_types};
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// One record to write: its body (its `$type` is its collection), and optionally its key and space.
#[derive(Debug, Deserialize)]
pub struct Record {
    pub record: Value,
    #[serde(default)]
    pub rkey: Option<String>,
    #[serde(default)]
    pub space: Option<String>,
}

/// One write, as it goes out.
#[derive(Debug, Serialize, PartialEq)]
pub struct Write {
    pub uri: String,
    pub cid: String,
    pub collection: String,
    pub rkey: String,
    pub record: Value,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub space: Option<String>,
    /// Through the private batch write rather than the PDS.
    pub private: bool,
    /// The platform has a lexicon for its type. A record without one (another app's own type, P18.1)
    /// is never curated: the platform answers it "not curated: no lexicon" (`put` passes that on as
    /// the record's status), and `put` does not wait for a curated version of it.
    pub lexicon: bool,
}

/// One record to delete: its key (`collection` + `rkey`, or its `uri` in the writer's own repo), and
/// its space when it was placed in one.
#[derive(Debug, Deserialize)]
pub struct DeleteRecord {
    #[serde(default)]
    pub uri: Option<String>,
    #[serde(default)]
    pub collection: Option<String>,
    #[serde(default)]
    pub rkey: Option<String>,
    #[serde(default)]
    pub space: Option<String>,
}

/// One delete, as it goes out.
#[derive(Debug, Serialize, PartialEq)]
pub struct Delete {
    pub uri: String,
    pub collection: String,
    pub rkey: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub space: Option<String>,
    /// Through the platform's private delete rather than the PDS.
    pub private: bool,
}

/// A put with deletes: the writes ([`plan`]'s order) and the deletes in the order they go out: the
/// public ones first (in the same `applyWrites` as the public writes), then the private ones, each
/// group in the order given.
#[derive(Debug, Serialize, PartialEq)]
pub struct Plan {
    pub writes: Vec<Write>,
    pub deletes: Vec<Delete>,
}

/// [`plan`] for `records`, plus the deletes for `deletes`. A key both written and deleted, or deleted
/// twice, is an error. Native only: it reads the system clock ([`plan_with_deletes_at`] takes one).
#[cfg(not(target_arch = "wasm32"))]
pub fn plan_with_deletes(repo: &str, records: Vec<Record>, deletes: Vec<DeleteRecord>) -> Result<Plan, String> {
    plan_with_deletes_at(repo, records, deletes, unix_now())
}

/// [`plan_with_deletes`] as of `now_unix` (the clock [`plan_at`] reads).
pub fn plan_with_deletes_at(repo: &str, records: Vec<Record>, deletes: Vec<DeleteRecord>, now_unix: u64) -> Result<Plan, String> {
    let writes = plan_at(repo, records, now_unix)?;
    let mut deletes = deletes.into_iter().map(|d| delete(repo, d)).collect::<Result<Vec<_>, _>>()?;
    if let Some(both) = deletes.iter().find(|d| writes.iter().any(|w| w.uri == d.uri)) {
        return Err(format!("{}: both written and deleted in one put", both.uri));
    }
    if let Some((_, twice)) = deletes.iter().enumerate().find(|(i, d)| deletes[..*i].iter().any(|e| e.uri == d.uri)) {
        return Err(format!("{}: deleted twice in one put", twice.uri));
    }
    deletes.sort_by_key(|d| d.private);
    Ok(Plan { writes, deletes })
}

/// An NSID's shape, loosely: dotted segments of letters, digits and hyphens, at least three.
fn is_nsid(s: &str) -> bool {
    let segments: Vec<&str> = s.split('.').collect();
    segments.len() >= 3 && segments.iter().all(|g| !g.is_empty() && g.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
}

/// atproto record-key syntax: 1-512 of `A-Za-z0-9 . _ : ~ -`, not `.` or `..`.
fn is_rkey(s: &str) -> bool {
    (1..=512).contains(&s.len())
        && s != "."
        && s != ".."
        && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '~' | '-'))
}

fn delete(repo: &str, DeleteRecord { uri, collection, rkey, space }: DeleteRecord) -> Result<Delete, String> {
    let from_uri = |uri: &str| -> Result<(String, String), String> {
        let rest = uri.strip_prefix("at://").ok_or_else(|| format!("{uri}: not an at:// URI"))?;
        let mut parts = rest.split('/');
        match (parts.next(), parts.next(), parts.next(), parts.next()) {
            (Some(authority), Some(c), Some(k), None) => {
                if authority != repo {
                    return Err(format!("{uri}: not in {repo}'s repo; put deletes only the writer's own records (by DID)"));
                }
                Ok((c.to_owned(), k.to_owned()))
            }
            _ => Err(format!("{uri}: expected at://{{repo}}/{{collection}}/{{rkey}}")),
        }
    };
    let (collection, rkey) = match (uri, collection, rkey) {
        (Some(uri), None, None) => from_uri(&uri)?,
        (None, Some(c), Some(k)) => (c, k),
        // A planned write passed back as a delete names all three; they must agree.
        (Some(uri), Some(c), Some(k)) => {
            if from_uri(&uri)? != (c.clone(), k.clone()) {
                return Err(format!("{uri}: its uri names another key than {c}/{k}"));
            }
            (c, k)
        }
        _ => return Err("a delete names its uri, or its collection and rkey".to_owned()),
    };
    if !is_nsid(&collection) {
        return Err(format!("{collection}: not a collection NSID"));
    }
    if !is_rkey(&rkey) {
        return Err(format!("{rkey}: not a record key"));
    }
    if let Some(space) = &space {
        if !space.starts_with("at://") {
            return Err(format!("{space}: a space is an at:// URI"));
        }
    }
    Ok(Delete {
        uri: format!("at://{repo}/{collection}/{rkey}"),
        // A name from before a rename (N1) is placed as the type it is now, like a write.
        private: space.is_some() || (is_ours(&collection) && !has_public_home(current_name(&collection))),
        collection,
        rkey,
        space,
    })
}

/// The writes for `records` into `repo`'s own repo: the private ones first, then the public ones,
/// each group in the order given. Native only: it reads the system clock ([`plan_at`] takes one).
#[cfg(not(target_arch = "wasm32"))]
pub fn plan(repo: &str, records: Vec<Record>) -> Result<Vec<Write>, String> {
    plan_at(repo, records, unix_now())
}

/// [`plan`] as of `now_unix` (unix seconds): the day decides whether an old name's rename is still
/// honoured, and so whether the platform still has a lexicon for it (`Write::lexicon`).
pub fn plan_at(repo: &str, records: Vec<Record>, now_unix: u64) -> Result<Vec<Write>, String> {
    let mut writes = records.into_iter().map(|r| write(repo, r, now_unix)).collect::<Result<Vec<_>, _>>()?;
    writes.sort_by_key(|w| !w.private);
    Ok(writes)
}

/// The clock [`plan`] reads: the system's. The WebAssembly has none (`SystemTime::now` panics there),
/// so neither it nor `plan` is built there, and [`crate::call`] refuses `put` without the caller's
/// `now` (as of 0, every old name would still have its rename, and so a lexicon).
#[cfg(not(target_arch = "wasm32"))]
fn unix_now() -> u64 {
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}

fn write(repo: &str, Record { record, rkey, space }: Record, now_unix: u64) -> Result<Write, String> {
    let collection = record.get("$type").and_then(Value::as_str).ok_or("a record without a $type")?.to_owned();
    let cid = dag_cbor::cid_for_json(&record).map_err(|e| format!("{collection}: {}", e.0))?;
    // A name from before a rename (N1) is placed and keyed as the type it is now; it is written under
    // the name given.
    let current = current_name(&collection);
    let rkey = match (rkey, record_key(current).and_then(|k| k.strip_prefix("literal:"))) {
        (Some(rkey), _) => rkey,
        (None, Some(literal)) => literal.to_owned(),
        (None, None) => {
            let created = record.get("createdAt").and_then(Value::as_str).ok_or_else(|| {
                format!("{collection}: no rkey given, and no createdAt to derive one from")
            })?;
            let at = chrono::DateTime::parse_from_rfc3339(created).map_err(|e| format!("{collection}: createdAt: {e}"))?;
            let digest = cid.hash().digest();
            techne_lexicon_check::tid(at.timestamp_micros(), u16::from_be_bytes([digest[0], digest[1]]))
        }
    };
    Ok(Write {
        uri: format!("at://{repo}/{collection}/{rkey}"),
        cid: cid.to_string(),
        private: space.is_some() || (is_ours(&collection) && !has_public_home(current)),
        // Dated, as the platform's ingest: an old name is its type's only while its pair is honoured
        // (`renames::current_name_at`); after, the platform has no lexicon for it and never curates it.
        lexicon: is_record_type(&collection)
            || crate::renames::current_name_at(&collection, now_unix).is_some_and(is_record_type),
        collection,
        rkey,
        record,
        space,
    })
}

/// An NSID's namespace: its first two segments (`pub.techne`), lowercased, since an NSID's
/// authority is a domain and compares without case. `None` for a name with fewer than three segments.
fn namespace(nsid: &str) -> Option<String> {
    let mut segments = nsid.splitn(3, '.');
    let (a, b) = (segments.next()?, segments.next()?);
    segments.next()?;
    Some(format!("{a}.{b}").to_ascii_lowercase())
}

/// The platform's own namespaces, read off the embedded lexicons: those that hold a record type with
/// no public home (today only `pub.techne`; every other lexicon type here is public), and each
/// namespace a type there was renamed out of (`coop.lexicon`, N1), which stays the platform's.
fn private_namespaces() -> &'static [String] {
    static NAMESPACES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
    NAMESPACES.get_or_init(|| {
        let mut out: Vec<String> =
            record_types().into_iter().filter(|c| !has_public_home(c)).filter_map(namespace).collect();
        out.sort();
        out.dedup();
        let mut old: Vec<String> = crate::renames::pairs()
            .iter()
            .filter(|p| namespace(&p.new).is_some_and(|ns| out.contains(&ns)))
            .filter_map(|p| namespace(&p.old))
            .collect();
        out.append(&mut old);
        out.sort();
        out.dedup();
        out
    })
}

/// Whether `collection` is the platform's to place (P18.1 guard, main's pick 2026-10-24, decided by
/// lucian, relayed by main 2026-10-26): a lexicon type under its current name or one from before a
/// rename (N1), or any other name in a [`private_namespaces`] namespace. Such a name is a mistake (a
/// typo after the namespace, other letter case), so it stays private rather than reach the PDS.
fn is_ours(collection: &str) -> bool {
    // A community draft (`registry.publishType`, `pub.techne.draft.c.<id>.<name>`) sits under
    // `pub.techne` but is not the platform's to place: like another app's own type, its records are the
    // user's public records (private only with a `space`). The pinned check's rule decides it, so a name
    // that only looks like one (a malformed id, other letter case) stays private like any other typo.
    if techne_lexicon_check::runtime::community::is_community(collection) {
        return false;
    }
    is_record_type(current_name(collection)) || namespace(collection).is_some_and(|ns| private_namespaces().contains(&ns))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    /// The platform's NSID rename map ([`crate::renames`]: the crate's copy of the pinned input).
    const RENAMES_JSON: &str = crate::renames::JSON;

    const AT: &str = "2026-10-19T12:00:00.000Z";
    /// The clock the tests plan at: `AT` in unix seconds, inside every rename's window, so a test does
    /// not turn red on the day a pair closes (the 64 pre-N1 pairs close after 2027-04-09).
    const NOW: u64 = 1_792_411_200;

    fn one(collection: &str, space: Option<&str>) -> Write {
        let record = json!({"$type": collection, "text": "Honest walk", "createdAt": AT});
        plan_at("did:plc:alice", vec![Record { record, rkey: None, space: space.map(str::to_owned) }], NOW).unwrap().remove(0)
    }

    /// A lexicon type with no public home, and one with a public home in the same namespace.
    fn private_and_public_types() -> (&'static str, &'static str) {
        let private = record_types().into_iter().find(|c| !has_public_home(c)).expect("a private type");
        let ns = namespace(private);
        let public = record_types().into_iter().find(|c| has_public_home(c) && namespace(c) == ns).expect("a public type");
        (private, public)
    }

    #[test]
    fn a_community_draft_is_public_unless_a_space_is_named() {
        let c = "pub.techne.draft.c.abcdefghi7.toolListing";
        assert!(!one(c, None).private, "{c} goes to the user's repo");
        assert!(one(c, Some("at://did:plc:alice/pub.techne.draft.space.private/k")).private, "{c} in a space");
        // Still the platform's: a typo elsewhere in its namespace, and the bare prefix.
        assert!(one("pub.techne.draft.notAType", None).private);
        assert!(one("pub.techne.draft.cx.thing", None).private);
    }

    #[test]
    fn a_name_that_only_looks_like_a_community_draft_stays_private() {
        // The registry never mints these (community::is_id: 6-26 lower-case base32 from a letter, then a
        // name), so each is a typo under the platform's namespace, not someone's published type.
        for c in [
            "pub.techne.draft.c.r8fd.toolListing",        // id too short, and 8 is not base32
            "pub.techne.draft.c.7bcdefghi.toolListing",   // id starts with a digit
            "Pub.Techne.Draft.C.abcdefghi7.toolListing", // other letter case
            "pub.techne.draft.c.ABCDEFGHI7.toolListing", // upper-case id
            "pub.techne.draft.c.abcdefghi7",             // no name
        ] {
            assert!(!techne_lexicon_check::runtime::community::is_community(c), "{c}");
            assert!(one(c, None).private, "{c} stays private");
        }
    }

    #[test]
    fn a_record_without_a_type_or_anything_to_key_it_by_is_refused() {
        let repo = "did:plc:alice";
        assert!(plan(repo, vec![Record { record: json!({"name": "x"}), rkey: None, space: None }]).is_err());
        let bare = json!({"$type": "x.example.thing", "name": "x"});
        let err = plan(repo, vec![Record { record: bare.clone(), rkey: None, space: None }]).unwrap_err();
        assert!(err.contains("no createdAt"), "{err}");
        assert_eq!(plan(repo, vec![Record { record: bare, rkey: Some("k".into()), space: None }]).unwrap()[0].rkey, "k");
    }

    fn del(uri: Option<&str>, collection: Option<&str>, rkey: Option<&str>, space: Option<&str>) -> DeleteRecord {
        let s = |v: Option<&str>| v.map(str::to_owned);
        DeleteRecord { uri: s(uri), collection: s(collection), rkey: s(rkey), space: s(space) }
    }

    #[test]
    fn deletes_are_placed_like_writes_in_the_order_they_go_out() {
        let repo = "did:plc:alice";
        let event = "community.lexicon.calendar.event";
        let space = "at://did:plc:alice/pub.techne.draft.space.event.detail/k1";
        let p = plan_with_deletes(
            repo,
            vec![],
            vec![
                del(Some(&format!("at://{repo}/{event}/k1")), None, None, None),
                del(None, Some("pub.techne.draft.event.detail"), Some("k1"), Some(space)),
                del(None, Some("com.example.thirdparty.note"), Some("k2"), None),
            ],
        )
        .unwrap();
        assert!(p.writes.is_empty());
        assert_eq!(
            p.deletes.iter().map(|d| (d.collection.as_str(), d.private)).collect::<Vec<_>>(),
            [(event, false), ("com.example.thirdparty.note", false), ("pub.techne.draft.event.detail", true)]
        );
        assert_eq!(p.deletes[0].uri, format!("at://{repo}/{event}/k1"));
        assert_eq!(p.deletes[2].space.as_deref(), Some(space));
    }

    #[test]
    fn a_delete_outside_the_repo_or_of_a_key_also_written_or_without_a_key_is_refused() {
        let repo = "did:plc:alice";
        let event = json!({"$type": "community.lexicon.calendar.event", "name": "x", "startsAt": "2026-10-25T18:00:00.000Z", "createdAt": "2026-10-19T12:00:00.000Z"});
        let err = plan_with_deletes(repo, vec![], vec![del(Some("at://did:plc:bob/community.lexicon.calendar.event/k"), None, None, None)]).unwrap_err();
        assert!(err.contains("not in did:plc:alice's repo"), "{err}");
        let both = plan_with_deletes(
            repo,
            vec![Record { record: event, rkey: Some("k".into()), space: None }],
            vec![del(None, Some("community.lexicon.calendar.event"), Some("k"), None)],
        )
        .unwrap_err();
        assert!(both.contains("both written and deleted"), "{both}");
        assert!(plan_with_deletes(repo, vec![], vec![del(None, Some("x.y.z"), None, None)]).is_err());
        assert!(plan_with_deletes(repo, vec![], vec![del(Some("at://did:plc:alice/x.y.z"), None, None, None)]).is_err());
        let ev = "community.lexicon.calendar.event";
        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();
        assert!(twice.contains("deleted twice"), "{twice}");
        for (uri, c, k, space) in [
            (Some("at://did:plc:alice/x.y.z/k?q"), None, None, None),
            (Some("at://did:plc:alice/x.y.z/k#f"), None, None, None),
            (None, Some("x.y/z"), Some("k"), None),
            (None, Some("x.y.z"), Some(".."), None),
            (None, Some("x.y.z"), Some("k"), Some("")),
            (Some("at://alice.test/x.y.z/k"), None, None, None),
            (Some("at://did:plc:alice/x.y.z/other"), Some("x.y.z"), Some("k"), None),
        ] {
            assert!(plan_with_deletes(repo, vec![], vec![del(uri, c, k, space)]).is_err(), "{uri:?} {c:?} {k:?} {space:?}");
        }
        // A planned write passed back: uri, collection and rkey that agree.
        let back = plan_with_deletes(repo, vec![], vec![del(Some(&format!("at://{repo}/{ev}/k")), Some(ev), Some("k"), None)]).unwrap();
        assert_eq!(back.deletes[0].uri, format!("at://{repo}/{ev}/k"));
    }

    /// P18.1 (lucian 2026-10-24): a type with no lexicon goes to the user's PDS like any public record;
    /// a lexicon type with no public home, or any record in a space, stays private.
    #[test]
    fn a_type_with_no_lexicon_goes_to_the_users_pds() {
        assert!(!one("com.example.thirdparty.note", None).private, "no lexicon: the user's PDS");
        let space = "at://did:plc:alice/com.example.space/self";
        assert!(one("com.example.thirdparty.note", Some(space)).private, "a record in a space is private whatever its type");
        let (private, public) = private_and_public_types();
        assert!(one(private, None).private, "{private}: a lexicon type with no public home stays private");
        assert!(!one(public, None).private, "{public}: a lexicon type with a public home is public");
    }

    /// P18.1 lows: the plan says whether the platform has a lexicon for a record's type, so `put`
    /// does not wait for a curated version of one it never curates. An unknown name in the platform's
    /// own namespace has none either, though it stays private.
    #[test]
    fn the_plan_says_whether_the_platform_has_a_lexicon_for_the_type() {
        let (private, public) = private_and_public_types();
        assert!(one(private, None).lexicon && one(public, None).lexicon);
        assert!(!one("com.example.thirdparty.note", None).lexicon, "another app's own type");
        assert!(!one(&format!("{private}x"), None).lexicon, "a typo in the platform's namespace");
    }

    /// P18.1 guard (lucian's decision, relayed by main 2026-10-26): an unknown name inside the platform's own
    /// namespace (a typo, a name from before a rename, other letter case) stays private, so it never
    /// lands on the user's PDS; only other namespaces with no lexicon go there.
    #[test]
    fn an_unknown_type_in_the_platforms_namespace_stays_private() {
        let (private, public) = private_and_public_types();
        let ns = namespace(private).unwrap();
        let old_name = |c: &str| c.replacen(".draft.", ".", 1);
        let unknown = [
            format!("{private}x"),
            old_name(private),
            old_name(public),
            private.to_ascii_uppercase(),
            public.to_ascii_uppercase(),
        ];
        for collection in &unknown {
            assert!(!is_record_type(collection), "{collection} has no lexicon");
            assert!(one(collection, None).private, "{collection}: an unknown name in {ns} stays private");
        }
        let neighbour = format!("{ns}x.note");
        assert!(!one(&neighbour, None).private, "{neighbour}: only the namespace itself");
    }


    /// The rename map's pairs (the crate's copy), old -> new.
    fn renames() -> Vec<(String, String)> {
        crate::renames::pairs().iter().map(|p| (p.old.clone(), p.new.clone())).collect()
    }

    /// The crate's copy of the rename map is the platform's (the pinned input, byte for byte) and the
    /// check's, both ways: every pair is what `current_name` answers, and every name the check renames is
    /// a pair here. The check exposes no list, so the second direction asks it about every name a pair
    /// could have: each old name here, and each current type and renamed-to name under the spellings N1
    /// and the draft move renamed out of (`coop.lexicon.draft.X`, `coop.lexicon.X`).
    #[test]
    fn renames_copy_is_the_platforms() {
        let input = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../inputs/platform-api/nsid_renames.json");
        if let Ok(bytes) = std::fs::read_to_string(&input) {
            assert_eq!(bytes, RENAMES_JSON, "crates/techne-sdk/src/nsid_renames.json differs from {}: run scripts/inputs-sync.mjs", input.display());
        }
        let pairs = renames();
        assert!(!pairs.is_empty());
        for (old, new) in &pairs {
            assert_eq!(current_name(old), new, "the check renames {old} otherwise");
        }
        let map: std::collections::BTreeMap<&str, &str> = pairs.iter().map(|(o, n)| (o.as_str(), n.as_str())).collect();
        let currents: std::collections::BTreeSet<String> =
            record_types().into_iter().map(str::to_owned).chain(pairs.iter().map(|(_, n)| n.clone())).collect();
        let mut asked = 0;
        for current in &currents {
            let tail = current.strip_prefix("pub.techne.draft.").or_else(|| current.strip_prefix("social.scenius."));
            let candidates = tail
                .into_iter()
                .flat_map(|t| [format!("coop.lexicon.draft.{t}"), format!("coop.lexicon.{t}"), format!("social.scenius.{t}")]);
            for name in candidates.chain(std::iter::once(current.clone())) {
                asked += 1;
                let answered = current_name(&name);
                if answered != name {
                    assert_eq!(map.get(name.as_str()).copied(), Some(answered), "the check renames {name} to {answered}; this copy does not");
                }
            }
        }
        assert!(asked > pairs.len(), "the reverse direction asked about {asked} names");
    }

    /// `lexicon` is dated as the platform's ingest is: an old name counts as its type through its pair's
    /// last day and not after it (`closeEarlier`'s pairs on their own earlier day), a current name always.
    #[test]
    fn an_old_name_has_a_lexicon_only_while_its_rename_is_honoured() {
        let doc: Value = serde_json::from_str(RENAMES_JSON).unwrap();
        let day = |d: &str| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").unwrap();
        let at = |d: chrono::NaiveDate, h, m, s| u64::try_from(d.and_hms_opt(h, m, s).unwrap().and_utc().timestamp()).unwrap();
        let early: Vec<(chrono::NaiveDate, String)> = doc["closeEarlier"]
            .as_object()
            .unwrap()
            .iter()
            .flat_map(|(d, olds)| olds.as_array().unwrap().iter().map(move |o| (day(d), o.as_str().unwrap().to_owned())))
            .collect();
        let last_of = |old: &str| early.iter().find(|(_, o)| o == old).map_or_else(|| day(doc["deleteAfter"].as_str().unwrap()), |(d, _)| *d);
        let (mut early_seen, mut late_seen) = (false, false);
        for (old, new) in renames() {
            if !is_record_type(&new) || is_record_type(&old) {
                continue;
            }
            let last = last_of(&old);
            early_seen |= last != day(doc["deleteAfter"].as_str().unwrap());
            late_seen |= last == day(doc["deleteAfter"].as_str().unwrap());
            let lexicon = |now| {
                let record = json!({"$type": old, "text": "Honest walk", "createdAt": AT});
                plan_at("did:plc:alice", vec![Record { record, rkey: Some("k".into()), space: None }], now).unwrap()[0].lexicon
            };
            assert!(lexicon(at(last, 23, 59, 59)), "{old}: a lexicon through {last}");
            assert!(!lexicon(at(last + chrono::Days::new(1), 0, 0, 0)), "{old}: no lexicon after {last}");
            let record = json!({"$type": new, "text": "Honest walk", "createdAt": AT});
            assert!(plan_at("did:plc:alice", vec![Record { record, rkey: Some("k".into()), space: None }], u64::MAX / 2).unwrap()[0].lexicon);
        }
        assert!(early_seen && late_seen, "both kinds of window are checked");
    }

    /// N1: a name from before the rename is a type the platform still has, under its current name. An
    /// old name of a public type is public (an old build's record goes to the user's PDS, and the
    /// platform curates it through the mapping), one of a private type private; either keeps the name
    /// it was given and its type's key.
    #[test]
    fn an_old_name_is_placed_as_the_type_it_was_renamed_to() {
        let (mut public, mut private, mut literal) = (0, 0, 0);
        for (old, _) in renames() {
            let current = current_name(&old);
            if old == current || !is_record_type(current) {
                continue;
            }
            let w = one(&old, None);
            assert_eq!(w.private, !has_public_home(current), "{old} -> {current}");
            assert!(w.lexicon, "{old}: the platform has {current}");
            assert_eq!(w.collection, old, "written under the name given");
            if let Some(key) = record_key(current).and_then(|k| k.strip_prefix("literal:")) {
                assert_eq!(w.rkey, key, "{old}: {current}'s key");
                literal += 1;
            }
            if w.private { private += 1 } else { public += 1 }
        }
        assert!(public > 0 && private > 0 && literal > 0, "public {public}, private {private}, literal {literal}");
    }

    /// N1: a delete under a name from before the rename is placed as the type it is now, like a write:
    /// an old name of a public type goes in the public `applyWrites`, one of a private type through
    /// the private delete; the delete keeps the name given.
    #[test]
    fn a_delete_under_an_old_name_is_placed_as_the_type_it_was_renamed_to() {
        let repo = "did:plc:alice";
        let (mut public, mut private) = (0, 0);
        for (old, _) in renames() {
            let current = current_name(&old);
            if old == current || !is_record_type(current) {
                continue;
            }
            let p = plan_with_deletes(repo, vec![], vec![del(None, Some(&old), Some("k1"), None)]).unwrap();
            let d = &p.deletes[0];
            assert_eq!(d.private, !has_public_home(current), "{old} -> {current}");
            assert_eq!((d.collection.as_str(), d.uri.clone()), (old.as_str(), format!("at://{repo}/{old}/k1")), "deleted under the name given");
            if d.private { private += 1 } else { public += 1 }
        }
        assert!(public > 0 && private > 0, "public {public}, private {private}");
    }

    /// N1: the namespace a type was renamed out of is still the platform's: an unknown name there (a
    /// typo, other letter case) stays private, like one in the current namespace.
    #[test]
    fn an_unknown_name_in_the_old_namespace_stays_private() {
        let renames = renames();
        let old = |public: bool| {
            renames
                .iter()
                .find(|(o, n)| o != n && is_record_type(n) && has_public_home(n) == public && namespace(o) != namespace(n))
                .map(|(o, _)| o.clone())
                .expect("a renamed type")
        };
        for old in [old(true), old(false)] {
            for collection in [format!("{old}x"), old.to_ascii_uppercase()] {
                assert!(one(&collection, None).private, "{collection}: an unknown name in {:?}", namespace(&old));
                let p = plan_with_deletes("did:plc:alice", vec![], vec![del(None, Some(&collection), Some("k1"), None)]).unwrap();
                assert!(p.deletes[0].private, "{collection}: its delete is private too");
            }
        }
    }
}