Read rest of wire.rs + perch, registryhost, lib, and CLI/test sites.


1312	            NOW,
1313	            0,    // step_back: the normal attempt
1314	            None, // intro: pre-M8 joiner shape
1315	        )
1316	        .await;
1317	        conn.close(0u32.into(), b"done");
1318
1319	        let (resp_trust, resp_result) = resp_task.await.expect("responder task");
1320
1321	        assert!(
1322	            matches!(init_result, Err(PairWireError::UnknownSubnet(ref n)) if n == "work"),
1323	            "joiner sees unknown subnet, got {init_result:?}"
1324	        );
1325	        assert!(matches!(resp_result, Err(PairWireError::UnknownSubnet(_))));
1326	        assert!(resp_trust.peers.is_empty());
1327	    }
1328
1329	    // [unit->REQ-PAIR-4] CREATE-NEW NAMES UP FRONT: the responder runs `create_subnet`
1330	    // (mints the seed at epoch 1 and names the subnet at creation — the sole-seed-holder
1331	    // path), then a second node pairs by that NAME + code. The joiner ends up holding the
1332	    // same NAMED subnet (R-PAIR-4 naming carried through the ceremony + seed transfer).
1333	    #[tokio::test]
1334	    async fn create_new_then_join_learns_named_subnet() {
1335	        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
1336	        let resp_pub = resp_id.public_key();
1337	        let init_pub = init_id.public_key();
1338	        let resp_addr = responder.addr();
1339
1340	        // The first node creates+names the subnet; read its minted seed to compute the code.
1341	        let mut subnets = SubnetStore::default();
1342	        let rec = subnets
1343	            .create_subnet("garage")
1344	            .expect("create-new names the subnet");
1345	        assert_eq!(rec.epoch, 1, "create-new starts at epoch 1");
1346	        let seed = rec.seed_bytes().expect("minted seed");
1347	        let code = code_for(seed);
1348
1349	        let resp_task = tokio::spawn(async move {
1350	            let incoming = responder.accept().await.expect("incoming");
1351	            let conn = incoming
1352	                .accept()
1353	                .expect("accept")
1354	                .await
1355	                .expect("connecting");
1356	            let mut rate = PairingRateLimiter::new();
1357	            let mut trust = TrustStore::default();
1358	            run_responder(&conn, resp_pub, &subnets, &RosterStore::default(), &mut rate, &mut trust, NOW)
1359	                .await
1360	                .expect("responder pairs");
1361	            conn.closed().await;
1362	        });
1363
1364	        let conn = initiator
1365	            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
1366	            .await
1367	            .expect("connect");
1368	        let mut init_trust = TrustStore::default();
1369	        let mut init_subnets = SubnetStore::default();
1370	        let mut init_roster = RosterStore::default();
1371	        run_initiator(
1372	            &conn,
1373	            init_pub,
1374	            "garage",
1375	            &code,
1376	            &mut init_trust,
1377	            &mut init_subnets,
1378	            &mut init_roster,
1379	            NOW,
1380	            0,    // step_back: the normal attempt
1381	            None, // intro: pre-M8 joiner shape
1382	        )
1383	        .await
1384	        .expect("initiator pairs");
1385	        conn.close(0u32.into(), b"done");
1386	        resp_task.await.expect("responder task");
1387
1388	        let joined = init_subnets
1389	            .find("garage")
1390	            .expect("joiner learns the named subnet");
1391	        assert_eq!(joined.seed_bytes(), Some(seed));
1392	        assert_eq!(joined.epoch, 1);
1393	    }
1394
1395	    // [unit->REQ-PAIR-5] IDEMPOTENT RE-PAIR: a node that already holds the subnet (and
1396	    // already trusts the peer) re-pairs cleanly — the outcome is AlreadyTrusted and the
1397	    // existing seed record is KEPT, not overwritten (add_joined's Exists is ignored).
1398	    #[tokio::test]
1399	    async fn repair_member_keeps_seed() {
1400	        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
1401	        let resp_pub = resp_id.public_key();
1402	        let init_pub = init_id.public_key();
1403	        let seed = [4u8; 20];
1404	        let code = code_for(seed);
1405	        let resp_addr = responder.addr();
1406
1407	        let resp_task = tokio::spawn(async move {
1408	            let incoming = responder.accept().await.expect("incoming");
1409	            let conn = incoming
1410	                .accept()
1411	                .expect("accept")
1412	                .await
1413	                .expect("connecting");
1414	            let subnets = subnet_store("home", seed, 2);
1415	            let mut rate = PairingRateLimiter::new();
1416	            let mut trust = TrustStore::default();
1417	            let outcome = run_responder(&conn, resp_pub, &subnets, &RosterStore::default(), &mut rate, &mut trust, NOW)
1418	                .await
1419	                .expect("responder pairs");
1420	            conn.closed().await;
1421	            outcome
1422	        });
1423
1424	        // The joiner already trusts the responder AND already holds "home" with a DISTINCT
1425	        // stored seed (so we can prove the re-pair does not overwrite it).
1426	        let mut init_trust = TrustStore::default();
1427	        init_trust.record("home", &resp_pub.to_hex(), None, None);
1428	        let existing_seed = [99u8; 20];
1429	        let mut init_subnets = subnet_store("home", existing_seed, 7);
1430	        let mut init_roster = RosterStore::default();
1431
1432	        let conn = initiator
1433	            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
1434	            .await
1435	            .expect("connect");
1436	        let init_outcome = run_initiator(
1437	            &conn,
1438	            init_pub,
1439	            "home",
1440	            &code,
1441	            &mut init_trust,
1442	            &mut init_subnets,
1443	            &mut init_roster,
1444	            NOW,
1445	            0,    // step_back: the normal attempt
1446	            None, // intro: pre-M8 joiner shape
1447	        )
1448	        .await
1449	        .expect("re-pair succeeds");
1450	        conn.close(0u32.into(), b"done");
1451	        let resp_outcome = resp_task.await.expect("responder task");
1452
1453	        assert_eq!(
1454	            init_outcome,
1455	            TrustOutcome::AlreadyTrusted,
1456	            "idempotent trust"
1457	        );
1458	        assert_eq!(
1459	            resp_outcome.trust,
1460	            TrustOutcome::Pinned,
1461	            "responder's first sight of joiner"
1462	        );
1463	        // The pre-existing seed record is untouched (Exists ignored, not overwritten).
1464	        let kept = init_subnets.find("home").expect("still a member");
1465	        assert_eq!(kept.seed_bytes(), Some(existing_seed), "existing seed kept");
1466	        assert_eq!(kept.epoch, 7, "existing epoch kept");
1467	    }
1468
1469	    // [unit->REQ-SUBNET-7] the Hello intro is additive on the wire: absent
1470	    // decodes None (the pre-M8 joiner), present round-trips both fields,
1471	    // and a HALF-written intro is a protocol error — never a half-claim.
1472	    #[test]
1473	    fn hello_intro_is_additive_and_roundtrips() {
1474	        // Absent → None.
1475	        match decode_frame(&enc_hello("home", 7, None)).expect("decodes") {
1476	            Frame::Hello { intro, .. } => assert!(intro.is_none()),
1477	            _ => panic!("expected Hello"),
1478	        }
1479	        // Present → both fields intact.
1480	        let sent = NodeIntro {
1481	            label: "KITSUBITO".into(),
1482	            machine_id: "cd".repeat(32),
1483	        };
1484	        match decode_frame(&enc_hello("home", 7, Some(&sent))).expect("decodes") {
1485	            Frame::Hello {
1486	                subnet_name,
1487	                step,
1488	                intro,
1489	            } => {
1490	                assert_eq!(subnet_name, "home");
1491	                assert_eq!(step, 7);
1492	                assert_eq!(intro, Some(sent));
1493	            }
1494	            _ => panic!("expected Hello"),
1495	        }
1496	        // Half an intro (label field only) refuses, never half-claims.
1497	        let mut half = vec![T_HELLO];
1498	        push_field(&mut half, b"home");
1499	        half.extend_from_slice(&7u64.to_be_bytes());
1500	        push_field(&mut half, b"KITSUBITO");
1501	        assert!(matches!(
1502	            decode_frame(&half),
1503	            Err(PairWireError::Protocol(_))
1504	        ));
1505	    }
1506
1507	    // [unit->REQ-PAIR-1] the frame codec round-trips every variant and rejects a
1508	    // truncated body without panicking (the codec is pre-trust attacker-reachable).
1509	    #[test]
1510	    fn frame_codec_round_trips_and_rejects_garbage() {
1511	        let cases = [
1512	            enc_hello("home fleet", 57_037_037, None),
1513	            // The M8 intro-carrying Hello round-trips too.
1514	            enc_hello(
1515	                "home fleet",
1516	                57_037_037,
1517	                Some(&NodeIntro {
1518	                    label: "HFENDULEAM".into(),
1519	                    machine_id: "ab".repeat(32),
1520	                }),
1521	            ),
1522	            enc_announce(42),
1523	            enc_spake1(b"msg-a-bytes"),
1524	            enc_spake2(b"msg-b", &[7u8; TAG_LEN]),
1525	            enc_confirm(&[9u8; TAG_LEN]),
1526	            enc_done(true),
1527	            enc_done(false),
1528	            enc_seed(&[5u8; TOTP_SEED_LEN], 9, &[], &[]),
1529	            enc_abort(A_BACKOFF, 17),
1530	        ];
1531	        for body in cases {
1532	            decode_frame(&body).expect("valid frame decodes");
1533	        }
1534	        // A Hello claiming an 8-byte step but missing it is truncated, not a panic.
1535	        let mut bad = vec![T_HELLO];
1536	        push_field(&mut bad, b"home");
1537	        bad.extend_from_slice(&[0u8; 3]); // only 3 of the 8 step bytes
1538	        assert!(matches!(
1539	            decode_frame(&bad),
1540	            Err(PairWireError::Protocol(_))
1541	        ));
1542	        // Unknown tag is rejected.
1543	        assert!(matches!(
1544	            decode_frame(&[0xFE]),
1545	            Err(PairWireError::Protocol(_))
1546	        ));
1547	    }
1548
1549	    // [unit->REQ-MESH-2] the Seed frame carries the roster additively: a
1550	    // members+tombstones payload round-trips (subnet implicit ⇒ empty on the
1551	    // wire; address optional), an empty roster round-trips, a LEGACY short body
1552	    // (seed+epoch only) decodes to an empty roster, and a malformed trailing
1553	    // count is a protocol error — never a panic.
1554	    #[test]
1555	    fn seed_frame_roster_is_additive_and_roundtrips() {
1556	        let members = vec![
1557	            RosterEntry {
1558	                pubkey_hex: "aa".into(),
1559	                subnet: "home".into(), // enc ignores subnet (implicit)
1560	                label: "host-a".into(),
1561	                machine_id: "mid-a".into(),
1562	                address: Some(serde_json::json!({"addr": "1.2.3.4:5"})),
1563	                last_seen: "5".into(),
1564	                lease_epoch: 7,
1565	            },
1566	            RosterEntry {
1567	                pubkey_hex: "bb".into(),
1568	                subnet: "home".into(),
1569	                label: String::new(),
1570	                machine_id: String::new(),
1571	                address: None,
1572	                last_seen: "6".into(),
1573	                lease_epoch: 0,
1574	            },
1575	        ];
1576	        let tombs = vec![Tombstone {
1577	            pubkey_hex: "cc".into(),
1578	            subnet: "home".into(),
1579	            stamp: "9".into(),
1580	        }];
1581
1582	        match decode_frame(&enc_seed(&[1u8; TOTP_SEED_LEN], 3, &members, &tombs)).expect("decode") {
1583	            Frame::Seed {
1584	                seed,
1585	                epoch,
1586	                roster,
1587	                tombstones,
1588	            } => {
1589	                assert_eq!(seed, [1u8; TOTP_SEED_LEN]);
1590	                assert_eq!(epoch, 3);
1591	                assert_eq!(roster.len(), 2);
1592	                assert_eq!(roster[0].subnet, "", "subnet implicit on the wire");
1593	                assert_eq!(roster[0].pubkey_hex, "aa");
1594	                assert_eq!(roster[0].label, "host-a");
1595	                assert_eq!(roster[0].address, Some(serde_json::json!({"addr": "1.2.3.4:5"})));
1596	                assert_eq!(roster[0].lease_epoch, 7);
1597	                assert_eq!(roster[1].address, None, "absent address stays None");
1598	                assert_eq!(tombstones.len(), 1);
1599	                assert_eq!(tombstones[0].pubkey_hex, "cc");
1600	            }
1601	            _ => panic!("expected Seed"),
1602	        }
1603
1604	        // Empty roster round-trips.
1605	        match decode_frame(&enc_seed(&[2u8; TOTP_SEED_LEN], 1, &[], &[])).expect("decode") {
1606	            Frame::Seed {
1607	                roster, tombstones, ..
1608	            } => {
1609	                assert!(roster.is_empty() && tombstones.is_empty());
1610	            }
1611	            _ => panic!("expected Seed"),
1612	        }
1613
1614	        // A LEGACY short body (seed+epoch only) decodes to an empty roster —
1615	        // the additive guarantee for a pre-mesh peer.
1616	        let mut legacy = vec![T_SEED];
1617	        legacy.extend_from_slice(&[3u8; TOTP_SEED_LEN]);
1618	        legacy.extend_from_slice(&9u64.to_be_bytes());
1619	        match decode_frame(&legacy).expect("legacy decodes") {
1620	            Frame::Seed { epoch, roster, .. } => {
1621	                assert_eq!(epoch, 9);
1622	                assert!(roster.is_empty());
1623	            }
1624	            _ => panic!("expected Seed"),
1625	        }
1626
1627	        // Malformed trailing: claims one member but no entry follows ⇒ truncated.
1628	        let mut bad = vec![T_SEED];
1629	        bad.extend_from_slice(&[4u8; TOTP_SEED_LEN]);
1630	        bad.extend_from_slice(&1u64.to_be_bytes());
1631	        bad.extend_from_slice(&1u32.to_be_bytes());
1632	        assert!(matches!(decode_frame(&bad), Err(PairWireError::Protocol(_))));
1633	    }
1634	}
1635

            NOW,
            0,    // step_back: the normal attempt
            None, // intro: pre-M8 joiner shape
        )
        .await;
        conn.close(0u32.into(), b"done");

        let (resp_trust, resp_result) = resp_task.await.expect("responder task");

        assert!(
            matches!(init_result, Err(PairWireError::UnknownSubnet(ref n)) if n == "work"),
            "joiner sees unknown subnet, got {init_result:?}"
        );
        assert!(matches!(resp_result, Err(PairWireError::UnknownSubnet(_))));
        assert!(resp_trust.peers.is_empty());
    }

    // [unit->REQ-PAIR-4] CREATE-NEW NAMES UP FRONT: the responder runs `create_subnet`
    // (mints the seed at epoch 1 and names the subnet at creation — the sole-seed-holder
    // path), then a second node pairs by that NAME + code. The joiner ends up holding the
    // same NAMED subnet (R-PAIR-4 naming carried through the ceremony + seed transfer).
    #[tokio::test]
    async fn create_new_then_join_learns_named_subnet() {
        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
        let resp_pub = resp_id.public_key();
        let init_pub = init_id.public_key();
        let resp_addr = responder.addr();

        // The first node creates+names the subnet; read its minted seed to compute the code.
        let mut subnets = SubnetStore::default();
        let rec = subnets
            .create_subnet("garage")
            .expect("create-new names the subnet");
        assert_eq!(rec.epoch, 1, "create-new starts at epoch 1");
        let seed = rec.seed_bytes().expect("minted seed");
        let code = code_for(seed);

        let resp_task = tokio::spawn(async move {
            let incoming = responder.accept().await.expect("incoming");
            let conn = incoming
                .accept()
                .expect("accept")
                .await
                .expect("connecting");
            let mut rate = PairingRateLimiter::new();
            let mut trust = TrustStore::default();
            run_responder(&conn, resp_pub, &subnets, &RosterStore::default(), &mut rate, &mut trust, NOW)
                .await
                .expect("responder pairs");
            conn.closed().await;
        });

        let conn = initiator
            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
            .await
            .expect("connect");
        let mut init_trust = TrustStore::default();
        let mut init_subnets = SubnetStore::default();
        let mut init_roster = RosterStore::default();
        run_initiator(
            &conn,
            init_pub,
            "garage",
            &code,
            &mut init_trust,
            &mut init_subnets,
            &mut init_roster,
            NOW,
            0,    // step_back: the normal attempt
            None, // intro: pre-M8 joiner shape
        )
        .await
        .expect("initiator pairs");
        conn.close(0u32.into(), b"done");
        resp_task.await.expect("responder task");

        let joined = init_subnets
            .find("garage")
            .expect("joiner learns the named subnet");
        assert_eq!(joined.seed_bytes(), Some(seed));
        assert_eq!(joined.epoch, 1);
    }

    // [unit->REQ-PAIR-5] IDEMPOTENT RE-PAIR: a node that already holds the subnet (and
    // already trusts the peer) re-pairs cleanly — the outcome is AlreadyTrusted and the
    // existing seed record is KEPT, not overwritten (add_joined's Exists is ignored).
    #[tokio::test]
    async fn repair_member_keeps_seed() {
        let (responder, initiator, resp_id, init_id) = pair_endpoints().await;
        let resp_pub = resp_id.public_key();
        let init_pub = init_id.public_key();
        let seed = [4u8; 20];
        let code = code_for(seed);
        let resp_addr = responder.addr();

        let resp_task = tokio::spawn(async move {
            let incoming = responder.accept().await.expect("incoming");
            let conn = incoming
                .accept()
                .expect("accept")
                .await
                .expect("connecting");
            let subnets = subnet_store("home", seed, 2);
            let mut rate = PairingRateLimiter::new();
            let mut trust = TrustStore::default();
            let outcome = run_responder(&conn, resp_pub, &subnets, &RosterStore::default(), &mut rate, &mut trust, NOW)
                .await
                .expect("responder pairs");
            conn.closed().await;
            outcome
        });

        // The joiner already trusts the responder AND already holds "home" with a DISTINCT
        // stored seed (so we can prove the re-pair does not overwrite it).
        let mut init_trust = TrustStore::default();
        init_trust.record("home", &resp_pub.to_hex(), None, None);
        let existing_seed = [99u8; 20];
        let mut init_subnets = subnet_store("home", existing_seed, 7);
        let mut init_roster = RosterStore::default();

        let conn = initiator
            .connect_with_alpn(resp_addr, SPT_PAIR_ALPN)
            .await
            .expect("connect");
        let init_outcome = run_initiator(
            &conn,
            init_pub,
            "home",
            &code,
            &mut init_trust,
            &mut init_subnets,
            &mut init_roster,
            NOW,
            0,    // step_back: the normal attempt
            None, // intro: pre-M8 joiner shape
        )
        .await
        .expect("re-pair succeeds");
        conn.close(0u32.into(), b"done");
        let resp_outcome = resp_task.await.expect("responder task");

        assert_eq!(
            init_outcome,
            TrustOutcome::AlreadyTrusted,
            "idempotent trust"
        );
        assert_eq!(
            resp_outcome.trust,
            TrustOutcome::Pinned,
            "responder's first sight of joiner"
        );
        // The pre-existing seed record is untouched (Exists ignored, not overwritten).
        let kept = init_subnets.find("home").expect("still a member");
        assert_eq!(kept.seed_bytes(), Some(existing_seed), "existing seed kept");
        assert_eq!(kept.epoch, 7, "existing epoch kept");
    }

    // [unit->REQ-SUBNET-7] the Hello intro is additive on the wire: absent
    // decodes None (the pre-M8 joiner), present round-trips both fields,
    // and a HALF-written intro is a protocol error — never a half-claim.
    #[test]
    fn hello_intro_is_additive_and_roundtrips() {
        // Absent → None.
        match decode_frame(&enc_hello("home", 7, None)).expect("decodes") {
            Frame::Hello { intro, .. } => assert!(intro.is_none()),
            _ => panic!("expected Hello"),
        }
        // Present → both fields intact.
        let sent = NodeIntro {
            label: "KITSUBITO".into(),
            machine_id: "cd".repeat(32),
        };
        match decode_frame(&enc_hello("home", 7, Some(&sent))).expect("decodes") {
            Frame::Hello {
                subnet_name,
                step,
                intro,
            } => {
                assert_eq!(subnet_name, "home");
                assert_eq!(step, 7);
                assert_eq!(intro, Some(sent));
            }
            _ => panic!("expected Hello"),
        }
        // Half an intro (label field only) refuses, never half-claims.
        let mut half = vec![T_HELLO];
        push_field(&mut half, b"home");
        half.extend_from_slice(&7u64.to_be_bytes());
        push_field(&mut half, b"KITSUBITO");
        assert!(matches!(
            decode_frame(&half),
            Err(PairWireError::Protocol(_))
        ));
    }

    // [unit->REQ-PAIR-1] the frame codec round-trips every variant and rejects a
    // truncated body without panicking (the codec is pre-trust attacker-reachable).
    #[test]
    fn frame_codec_round_trips_and_rejects_garbage() {
        let cases = [
            enc_hello("home fleet", 57_037_037, None),
            // The M8 intro-carrying Hello round-trips too.
            enc_hello(
                "home fleet",
                57_037_037,
                Some(&NodeIntro {
                    label: "HFENDULEAM".into(),
                    machine_id: "ab".repeat(32),
                }),
            ),
            enc_announce(42),
            enc_spake1(b"msg-a-bytes"),
            enc_spake2(b"msg-b", &[7u8; TAG_LEN]),
            enc_confirm(&[9u8; TAG_LEN]),
            enc_done(true),
            enc_done(false),
            enc_seed(&[5u8; TOTP_SEED_LEN], 9, &[], &[]),
            enc_abort(A_BACKOFF, 17),
        ];
        for body in cases {
            decode_frame(&body).expect("valid frame decodes");
        }
        // A Hello claiming an 8-byte step but missing it is truncated, not a panic.
        let mut bad = vec![T_HELLO];
        push_field(&mut bad, b"home");
        bad.extend_from_slice(&[0u8; 3]); // only 3 of the 8 step bytes
        assert!(matches!(
            decode_frame(&bad),
            Err(PairWireError::Protocol(_))
        ));
        // Unknown tag is rejected.
        assert!(matches!(
            decode_frame(&[0xFE]),
            Err(PairWireError::Protocol(_))
        ));
    }

    // [unit->REQ-MESH-2] the Seed frame carries the roster additively: a
    // members+tombstones payload round-trips (subnet implicit ⇒ empty on the
    // wire; address optional), an empty roster round-trips, a LEGACY short body
    // (seed+epoch only) decodes to an empty roster, and a malformed trailing
    // count is a protocol error — never a panic.
    #[test]
    fn seed_frame_roster_is_additive_and_roundtrips() {
        let members = vec![
            RosterEntry {
                pubkey_hex: "aa".into(),
                subnet: "home".into(), // enc ignores subnet (implicit)
                label: "host-a".into(),
                machine_id: "mid-a".into(),
                address: Some(serde_json::json!({"addr": "1.2.3.4:5"})),
                last_seen: "5".into(),
                lease_epoch: 7,
            },
            RosterEntry {
                pubkey_hex: "bb".into(),
                subnet: "home".into(),
                label: String::new(),
                machine_id: String::new(),
                address: None,
                last_seen: "6".into(),
                lease_epoch: 0,
            },
        ];
        let tombs = vec![Tombstone {
            pubkey_hex: "cc".into(),
            subnet: "home".into(),
            stamp: "9".into(),
        }];

        match decode_frame(&enc_seed(&[1u8; TOTP_SEED_LEN], 3, &members, &tombs)).expect("decode") {
            Frame::Seed {
                seed,
                epoch,
                roster,
                tombstones,
            } => {
                assert_eq!(seed, [1u8; TOTP_SEED_LEN]);
                assert_eq!(epoch, 3);
                assert_eq!(roster.len(), 2);
                assert_eq!(roster[0].subnet, "", "subnet implicit on the wire");
                assert_eq!(roster[0].pubkey_hex, "aa");
                assert_eq!(roster[0].label, "host-a");
                assert_eq!(roster[0].address, Some(serde_json::json!({"addr": "1.2.3.4:5"})));
                assert_eq!(roster[0].lease_epoch, 7);
                assert_eq!(roster[1].address, None, "absent address stays None");
                assert_eq!(tombstones.len(), 1);
                assert_eq!(tombstones[0].pubkey_hex, "cc");
            }
            _ => panic!("expected Seed"),
        }

        // Empty roster round-trips.
        match decode_frame(&enc_seed(&[2u8; TOTP_SEED_LEN], 1, &[], &[])).expect("decode") {
            Frame::Seed {
                roster, tombstones, ..
            } => {
                assert!(roster.is_empty() && tombstones.is_empty());
            }
            _ => panic!("expected Seed"),
        }

        // A LEGACY short body (seed+epoch only) decodes to an empty roster —
        // the additive guarantee for a pre-mesh peer.
        let mut legacy = vec![T_SEED];
        legacy.extend_from_slice(&[3u8; TOTP_SEED_LEN]);
        legacy.extend_from_slice(&9u64.to_be_bytes());
        match decode_frame(&legacy).expect("legacy decodes") {
            Frame::Seed { epoch, roster, .. } => {
                assert_eq!(epoch, 9);
                assert!(roster.is_empty());
            }
            _ => panic!("expected Seed"),
        }

        // Malformed trailing: claims one member but no entry follows ⇒ truncated.
        let mut bad = vec![T_SEED];
        bad.extend_from_slice(&[4u8; TOTP_SEED_LEN]);
        bad.extend_from_slice(&1u64.to_be_bytes());
        bad.extend_from_slice(&1u32.to_be_bytes());
        assert!(matches!(decode_frame(&bad), Err(PairWireError::Protocol(_))));
    }
}


1	//! Perch layout — the **single source of truth** for where a perch lives on
2	//! disk. Every reader and writer of a perch path routes through
3	//! [`resolve_perch_path`]; nothing composes perch paths by hand. If the layout
4	//! ever changes, exactly this one module changes (KNOWN-HAZARDS 6.1 / 1.5).
5	//!
6	//! Adapted from sister `perch_path.rs` + `owlery.rs` path primitives
7	//! (ADR-0001), with two clean-room changes for the spt-core model:
8	//! - **Taxonomy:** the sister's Spine/Touch perch kinds are gone (CONTEXT
9	//!   endpoint types are ReadyAgent/LiveAgent/Psyche/Worker/SptNode). A top-level
10	//!   agent perch (ReadyAgent or a LiveAgent's Self) classifies as [`PerchKind::Self_`].
11	//! - **Purity:** path composition performs NO filesystem I/O — every function
12	//!   returns a `PathBuf`. Directory creation is the writer's job (e.g. the spool
13	//!   `create_dir_all`s on open).
14	//!
15	//! Layout invariant:
16	//! - `Self_`            → flat: `owlery/<id>/`
17	//! - `Psyche` / `Worker`→ nested under parent: `owlery/<parent>/nested/<child>/`
18	//!
19	//! The single `nested/` namespace holds both Psyche and Worker children — no
20	//! divergent sibling layouts (the hazard this module exists to prevent).
21
22	use std::path::{Path, PathBuf};
23
24	/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
25	/// default (`%LOCALAPPDATA%\spt-core` on Windows, `$HOME/.spt-core` on Unix).
26	/// **Pure** — reads env, composes a path, creates nothing.
27	///
28	/// The default lives under `spt-core/`, **deliberately distinct from the modern
29	/// spt runtime's `spt/` home**, so a dev build never collides with a running
30	/// spt-plugin during the parity build-out. Once spt-core + the spt-plugin reach
31	/// full parity with modern spt this may simply stay `spt-core/` for good; the
32	/// override (`$SPT_HOME`) is the relocation knob either way.
33	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
34	pub fn spt_home() -> PathBuf {
35	    if let Ok(over) = std::env::var("SPT_HOME") {
36	        if !over.is_empty() {
37	            return PathBuf::from(over);
38	        }
39	    }
40	    platform_default_spt_home()
41	}
42
43	#[cfg(windows)]
44	fn platform_default_spt_home() -> PathBuf {
45	    if let Ok(local) = std::env::var("LOCALAPPDATA") {
46	        if !local.is_empty() {
47	            return PathBuf::from(local).join("spt-core");
48	        }
49	    }
50	    let up =
51	        std::env::var("USERPROFILE").expect("LOCALAPPDATA or USERPROFILE must be set on Windows");
52	    PathBuf::from(up)
53	        .join("AppData")
54	        .join("Local")
55	        .join("spt-core")
56	}
57
58	#[cfg(unix)]
59	fn platform_default_spt_home() -> PathBuf {
60	    let home = std::env::var("HOME").expect("HOME must be set on Unix");
61	    PathBuf::from(home).join(".spt-core")
62	}
63
64	/// The owlery directory holding all perches: `<spt_home>/owlery/`.
65	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
66	pub fn owlery_dir() -> PathBuf {
67	    spt_home().join("owlery")
68	}
69
70	/// The synced-mind context tree: `<spt_home>/tracked/` (`docs/STORAGE.md`).
71	/// A git-backed two-tier store (`crate::contextstore`) — **durable, synced
72	/// across nodes** (the backup/migrate set), physically separate from the
73	/// never-synced `perches/` runtime. Pure path composition.
74	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
75	pub fn tracked_dir() -> PathBuf {
76	    spt_home().join("tracked")
77	}
78
79	/// Node-local **durable** identity/security material: `<spt_home>/identity/`
80	/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
81	/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
82	/// material (the node keypair + subnet TOTP seeds). Pure path composition.
83	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
84	pub fn identity_dir() -> PathBuf {
85	    spt_home().join("identity")
86	}
87
88	/// The node's durable Ed25519 identity seed: `<spt_home>/identity/node.key`
89	/// (`docs/STORAGE.md`). The node's stable name on the wire (the iroh EndpointId
90	/// *is* this keypair's public key, D1/D4) — per-node secret material, never
91	/// git-tracked, never synced.
92	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
93	pub fn node_key_file() -> PathBuf {
94	    identity_dir().join("node.key")
95	}
96
97	/// The subnet membership + seed file: `<spt_home>/identity/subnet.json`
98	/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to
99	/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.
100	/// Replicated via pairing/P2P, **not** git.
101	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
102	pub fn subnet_file() -> PathBuf {
103	    identity_dir().join("subnet.json")
104	}
105
106	/// The member-roster file: `<spt_home>/identity/roster.json` (REQ-MESH-2,
107	/// ADR-0017). The subnet discovery directory — a node-level union-merge
108	/// grow-set of `(pubkey, label, machine_id, address, last-seen)` per member,
109	/// with per-pubkey tombstones. Node-local, replicated via the
110	/// pairing/seed-proof'd P2P channel, **never** git-tracked/synced (same class
111	/// as `subnet.json`/`peers.json`). NOT the seed.
112	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE] [impl->REQ-MESH-2]
113	pub fn roster_file() -> PathBuf {
114	    identity_dir().join("roster.json")
115	}
116
117	/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
118	/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
119	/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).
120	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
121	pub fn epoch_file() -> PathBuf {
122	    identity_dir().join("epoch")
123	}
124
125	/// The per-`(endpoint, subnet)` visibility + sync-membership config file:
126	/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
127	/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
128	/// its *effect*: presence/absence in the subnet registry).
129	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
130	pub fn visibility_file() -> PathBuf {
131	    identity_dir().join("visibility.json")
132	}
133
134	/// The subnet notification spool: `<spt_home>/identity/notifs.db` (ADR-0007, D8).
135	/// Node-level (a notif targets the *user in a subnet*, not a perch), subnet-tagged
136	/// rows; the *content* replicates across each subnet's nodes via the P2P feed
137	/// (like `subnet.json`'s material — never git).
138	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
139	pub fn notifs_file() -> PathBuf {
140	    identity_dir().join("notifs.db")
141	}
142
143	/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
144	/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
145	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
146	pub fn trust_dir() -> PathBuf {
147	    identity_dir().join("trust")
148	}
149
150	/// The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store
151	/// of authorized peer node pubkeys (R-PAIR-2). Node-local, never git-tracked/synced.
152	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
153	pub fn trust_file() -> PathBuf {
154	    trust_dir().join("peers.json")
155	}
156
157	/// The endpoint access whitelist: `<spt_home>/identity/trust/access.json`
158	/// (ADR-0009, REQ-SEC-1). Security material next to the trust store —
159	/// node-local, never git-tracked/synced.
160	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
161	pub fn access_file() -> PathBuf {
162	    trust_dir().join("access.json")
163	}
164
165	/// The recent-outbound correlation log:
166	/// `<spt_home>/identity/trust/recent-outbound.json` — the stateful-firewall
167	/// reply-correlation state the access gate reads (REQ-SEC-1). Node-local,
168	/// never synced.
169	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
170	pub fn recent_outbound_file() -> PathBuf {
171	    trust_dir().join("recent-outbound.json")
172	}
173
174	/// The consent grant store: `<spt_home>/identity/trust/grants.json`
175	/// (CONTEXT §Consent & security gates, REQ-CONSENT-1). Security material next
176	/// to the trust store — node-local, never git-tracked/synced. Default-deny
177	/// (the access whitelist's opposite polarity).
178	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
179	pub fn grants_file() -> PathBuf {
180	    trust_dir().join("grants.json")
181	}
182
183	/// The registered-adapter set: `<spt_home>/adapters/` (`docs/STORAGE.md`,
184	/// REQ-INSTALL-4) — one subdir per adapter (`record.toml` + a manifest copy
185	/// or pointer). Node-local capability (drive/launch), not subnet
186	/// advertisement.
187	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
188	pub fn adapters_dir() -> PathBuf {
189	    spt_home().join("adapters")
190	}
191
192	/// The shells namespace under an owner's Self perch:
193	/// `owlery/<owner>/shells/` (`docs/STORAGE.md` §Shells in the layout, M5-D3a).
194	/// A third namespace beside flat-Self and `nested/` — shell instances are
195	/// **not** perch kinds: [`resolve_perch_path`] never resolves them, and
196	/// [`list_self_perch_ids`] never enumerates them (they sit *inside* an
197	/// owner's dir, invisible to the top-level scan by construction). Pure path
198	/// composition, no I/O.
199	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
200	// [impl->REQ-SHELL-1]
201	pub fn resolve_shells_dir_in(owlery: &Path, owner: &str) -> PathBuf {
202	    owlery.join(owner).join("shells")
203	}
204
205	/// [`resolve_shells_dir_in`] against the canonical [`owlery_dir`].
206	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
207	pub fn resolve_shells_dir(owner: &str) -> PathBuf {
208	    resolve_shells_dir_in(&owlery_dir(), owner)
209	}
210
211	/// **The** shell-instance perch resolver:
212	/// `owlery/<owner>/shells/<adapter>-<n>/`. Every reader/writer of a shell
213	/// perch routes through here — same single-source rule as agent perches
214	/// (KNOWN-HAZARDS 6.1).
215	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
216	// [impl->REQ-SHELL-1]
217	pub fn resolve_shell_perch_path_in(owlery: &Path, owner: &str, shell_id: &str) -> PathBuf {
218	    resolve_shells_dir_in(owlery, owner).join(shell_id)
219	}
220
221	/// [`resolve_shell_perch_path_in`] against the canonical [`owlery_dir`].
222	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
223	pub fn resolve_shell_perch_path(owner: &str, shell_id: &str) -> PathBuf {
224	    resolve_shell_perch_path_in(&owlery_dir(), owner, shell_id)
225	}
226
227	/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
228	///
229	/// - `<x>-psyche` → [`PerchKind::Psyche`]
230	/// - `<x>-w{N}`   → [`PerchKind::Worker`] (`{N}` = one-or-more ASCII digits, `<x>` non-empty)
231	/// - anything else → [`PerchKind::Self_`]
232	#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233	pub enum PerchKind {
234	    /// Top-level agent perch (a ReadyAgent, or a LiveAgent's Self).
235	    Self_,
236	    /// A LiveAgent's Psyche companion perch.
237	    Psyche,
238	    /// A subagent perch under a parent LiveAgent.
239	    Worker,
240	}
241
242	impl PerchKind {
243	    /// Classify an id by suffix. Worker is checked FIRST (rightmost `-w{digits}`)
244	    /// so `dunsen-psyche-w1` classifies as `Worker`.
245	    // [impl->REQ-HAZARD-WORKER-PATH]
246	    pub fn classify(id: &str) -> PerchKind {
247	        if is_worker_id(id) {
248	            return PerchKind::Worker;
249	        }
250	        if id.ends_with("-psyche") && id.len() > "-psyche".len() {
251	            return PerchKind::Psyche;
252	        }
253	        PerchKind::Self_
254	    }
255
256	    /// True for kinds nested under a parent (Psyche, Worker); false for Self_.
257	    pub fn nests_under_parent(self) -> bool {
258	        matches!(self, PerchKind::Psyche | PerchKind::Worker)
259	    }
260	}
261
262	/// Worker-id rule: rightmost `-w` whose suffix is one-or-more digits, with a
263	/// non-empty name before it.
264	fn is_worker_id(id: &str) -> bool {
265	    if let Some(pos) = id.rfind("-w") {
266	        if pos == 0 {
267	            return false;
268	        }
269	        let suffix = &id[pos + 2..];
270	        !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
271	    } else {
272	        false
273	    }
274	}
275
276	/// How the resolver determines the parent id for nested kinds.
277	/// `Explicit` is preferred (caller knows the parent); `Infer` derives it by
278	/// stripping the kind suffix from the child id (legacy reader call sites).
279	#[derive(Debug, Clone)]
280	pub enum ParentHint<'a> {
281	    Explicit(&'a str),
282	    Infer,
283	}
284
285	/// **The** perch-directory resolver. Self_ → flat `owlery/<id>/`;
286	/// Psyche/Worker → `owlery/<parent>/nested/<child>/`. Panic-free: if `Infer`
287	/// cannot derive a parent (id shape doesn't match its kind — should not happen
288	/// since classify+infer agree), falls back to flat for safety.
289	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
290	// [impl->REQ-HAZARD-WORKER-PATH]
291	pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
292	    resolve_perch_path_in(&owlery_dir(), child_id, hint)
293	}
294
295	/// [`resolve_perch_path`] against an **explicit** owlery root rather than the
296	/// process-global [`owlery_dir`]. Same single-source layout logic — this is the
297	/// real resolver and [`resolve_perch_path`] is the canonical-root wrapper. Used
298	/// by callers that already hold an owlery root (e.g. the registry's stale-clean,
299	/// which resolves a row's perch to read its daemon-managed `status`) so they
300	/// resolve consistently with the owlery they operate on, not the global home.
301	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
302	// [impl->REQ-HAZARD-WORKER-PATH]
303	pub fn resolve_perch_path_in(owlery: &Path, child_id: &str, hint: ParentHint<'_>) -> PathBuf {
304	    let kind = PerchKind::classify(child_id);
305	    if !kind.nests_under_parent() {
306	        return owlery.join(child_id);
307	    }
308	    let parent = match hint {
309	        ParentHint::Explicit(p) => p.to_string(),
310	        ParentHint::Infer => match infer_parent(child_id, kind) {
311	            Some(p) => p,
312	            None => return owlery.join(child_id), // defensive flat fallback
313	        },
314	    };
315	    owlery.join(parent).join("nested").join(child_id)
316	}
317
318	/// Derive the parent id of a nested child by stripping its kind suffix.
319	fn infer_parent(id: &str, kind: PerchKind) -> Option<String> {
320	    match kind {
321	        PerchKind::Psyche => id.strip_suffix("-psyche").map(str::to_string),
322	        PerchKind::Worker => {
323	            let pos = id.rfind("-w")?;
324	            if pos == 0 {
325	                return None;
326	            }
327	            let suffix = &id[pos + 2..];
328	            if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_digit()) {
329	                return None;
330	            }
331	            Some(id[..pos].to_string())
332	        }
333	        PerchKind::Self_ => None,
334	    }
335	}
336
337	/// `<resolved_perch>/info.json`.
338	pub fn resolve_info_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
339	    info_file_at(&resolve_perch_path(child_id, hint))
340	}
341
342	/// `<resolved_perch>/spool.db` — the spool the path-aware spool surface opens.
343	pub fn resolve_spool_db(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
344	    resolve_perch_path(child_id, hint).join("spool.db")
345	}
346
347	/// `<resolved_perch>/ready`.
348	pub fn resolve_ready_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
349	    resolve_perch_path(child_id, hint).join("ready")
350	}
351
352	/// `<resolved_perch>/inbox`.
353	pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
354	    resolve_perch_path(child_id, hint).join("inbox")
355	}
356
357	/// The activity/idle sentinel filename within a perch dir (spt-core-owned; the
358	/// adapter never names it — MANIFEST §What is NOT in the manifest).
359	pub const IDLE_SENTINEL: &str = ".idle";
360	/// The echo-commune gate sentinel filename (`.more-done`-equiv; the idle
361	/// transition arms it). spt-core-owned.
362	pub const ECHO_GATE_SENTINEL: &str = ".more-done";
363
364	/// `<resolved_perch>/.idle` — the activity/idle sentinel.
365	pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
366	    resolve_perch_path(child_id, hint).join(IDLE_SENTINEL)
367	}
368
369	/// `<resolved_perch>/.more-done` — the echo-commune gate sentinel. The single
370	/// path source (6.1) shared by the `api` surface (which arms it) and the M2b
371	/// pulse (which reads + clears it).
372	pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
373	    resolve_perch_path(child_id, hint).join(ECHO_GATE_SENTINEL)
374	}
375
376	/// `<perch_path>/info.json` from an already-resolved path.
377	pub fn info_file_at(perch_path: &Path) -> PathBuf {
378	    perch_path.join("info.json")
379	}
380
381	/// Enumerate the **top-level `Self_` perch ids** in `owlery` — every immediate
382	/// subdirectory that carries an `info.json` (a bound perch). Pure dir scan,
383	/// tolerant: a missing/unreadable owlery, or an entry without `info.json`, is
384	/// silently skipped (never panics, never hard-fails). Nested Psyche/Worker
385	/// perches live under `<self>/nested/` and are deliberately excluded — the
386	/// consent-target resolver wants the user's *agent* sessions, not their hosted
387	/// children. Order is unspecified (the caller ranks by recency).
388	// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
389	pub fn list_self_perch_ids(owlery: &Path) -> Vec<String> {
390	    let Ok(entries) = std::fs::read_dir(owlery) else {
391	        return Vec::new();
392	    };
393	    let mut ids = Vec::new();
394	    for entry in entries.flatten() {
395	        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
396	            continue;
397	        }
398	        if !info_file_at(&entry.path()).is_file() {
399	            continue; // a dir without info.json is not a bound perch
400	        }
401	        if let Some(name) = entry.file_name().to_str() {
402	            ids.push(name.to_string());
403	        }
404	    }
405	    ids
406	}
407
408	#[cfg(test)]
409	mod tests {
410	    use super::*;
411	    use std::sync::Mutex;
412
413	    static ENV_LOCK: Mutex<()> = Mutex::new(());
414
415	    struct EnvGuard {
416	        _lock: std::sync::MutexGuard<'static, ()>,
417	        prev: Option<String>,
418	    }
419	    impl EnvGuard {
420	        fn set(tmp: &Path) -> Self {
421	            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
422	            let prev = std::env::var("SPT_HOME").ok();
423	            std::env::set_var("SPT_HOME", tmp);
424	            EnvGuard { _lock: lock, prev }
425	        }
426	    }
427	    impl Drop for EnvGuard {
428	        fn drop(&mut self) {
429	            match &self.prev {
430	                Some(v) => std::env::set_var("SPT_HOME", v),
431	                None => std::env::remove_var("SPT_HOME"),
432	            }
433	        }
434	    }
435
436	    // ---- classification ----
437
438	    // [unit->REQ-HAZARD-WORKER-PATH]
439	    #[test]
440	    fn classification_matrix() {
441	        assert_eq!(PerchKind::classify("dunsen"), PerchKind::Self_);
442	        assert_eq!(PerchKind::classify("dunsen-psyche"), PerchKind::Psyche);
443	        assert_eq!(PerchKind::classify("dunsen-w1"), PerchKind::Worker);
444	        assert_eq!(PerchKind::classify("dunsen-w42"), PerchKind::Worker);
445	        // Worker rule edges → Self_.
446	        assert_eq!(
447	            PerchKind::classify("-w1"),
448	            PerchKind::Self_,
449	            "no parent name"
450	        );
451	        assert_eq!(
452	            PerchKind::classify("dunsen-w"),
453	            PerchKind::Self_,
454	            "no digits"
455	        );
456	        assert_eq!(
457	            PerchKind::classify("dunsen-wa"),
458	            PerchKind::Self_,
459	            "non-digit"
460	        );
461	        // Rightmost -w wins.
462	        assert_eq!(PerchKind::classify("dunsen-psyche-w1"), PerchKind::Worker);
463	        // Dropped sister kinds are now plain Self_ (no Spine/Touch).
464	        assert_eq!(PerchKind::classify("dunsen-spine"), PerchKind::Self_);
465	        assert_eq!(PerchKind::classify("dunsen-touch"), PerchKind::Self_);
466	    }
467
468	    // ---- resolution: one resolver, no divergent layouts ----
469
470	    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Self_ is flat.
471	    #[test]
472	    fn resolve_self_is_flat() {
473	        let tmp = tempfile::tempdir().unwrap();
474	        let _g = EnvGuard::set(tmp.path());
475	        let owlery = tmp.path().join("owlery");
476	        assert_eq!(
477	            resolve_perch_path("dunsen", ParentHint::Infer),
478	            owlery.join("dunsen")
479	        );
480	    }
481
482	    // [unit->REQ-HAZARD-WORKER-PATH] Psyche + Worker nest under the single
483	    // `nested/` namespace, via both Explicit and Infer.
484	    #[test]
485	    fn resolve_psyche_and_worker_nest_under_parent() {
486	        let tmp = tempfile::tempdir().unwrap();
487	        let _g = EnvGuard::set(tmp.path());
488	        let owlery = tmp.path().join("owlery");
489
490	        let psyche_nested = owlery.join("dunsen").join("nested").join("dunsen-psyche");
491	        assert_eq!(
492	            resolve_perch_path("dunsen-psyche", ParentHint::Infer),
493	            psyche_nested
494	        );
495	        assert_eq!(
496	            resolve_perch_path("dunsen-psyche", ParentHint::Explicit("dunsen")),
497	            psyche_nested
498	        );
499
500	        let worker_nested = owlery.join("dunsen").join("nested").join("dunsen-w42");
501	        assert_eq!(
502	            resolve_perch_path("dunsen-w42", ParentHint::Infer),
503	            worker_nested
504	        );
505	        assert_eq!(
506	            resolve_perch_path("dunsen-w42", ParentHint::Explicit("dunsen")),
507	            worker_nested
508	        );
509	    }
510
511	    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Explicit parent is trusted
512	    // mechanically (overrides what Infer would derive).
513	    #[test]
514	    fn explicit_parent_wins_over_infer() {
515	        let tmp = tempfile::tempdir().unwrap();
516	        let _g = EnvGuard::set(tmp.path());
517	        let owlery = tmp.path().join("owlery");
518	        assert_eq!(
519	            resolve_perch_path("dunsen-psyche", ParentHint::Explicit("doyle")),
520	            owlery.join("doyle").join("nested").join("dunsen-psyche")
521	        );
522	    }
523
524	    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] child-file helpers all hang off the
525	    // one resolver — spool.db / info.json / ready / inbox share the perch root.
526	    #[test]
527	    fn child_files_route_through_one_resolver() {
528	        let tmp = tempfile::tempdir().unwrap();
529	        let _g = EnvGuard::set(tmp.path());
530	        let root = resolve_perch_path("dunsen-w7", ParentHint::Explicit("dunsen"));
531	        assert_eq!(
532	            resolve_spool_db("dunsen-w7", ParentHint::Explicit("dunsen")),
533	            root.join("spool.db")
534	        );
535	        assert_eq!(
536	            resolve_info_file("dunsen-w7", ParentHint::Explicit("dunsen")),
537	            root.join("info.json")
538	        );
539	        assert_eq!(
540	            resolve_ready_file("dunsen-w7", ParentHint::Explicit("dunsen")),
541	            root.join("ready")
542	        );
543	        assert_eq!(
544	            resolve_inbox_dir("dunsen-w7", ParentHint::Explicit("dunsen")),
545	            root.join("inbox")
546	        );
547	    }
548
549	    // [unit->REQ-UPD-4] list_self_perch_ids returns top-level bound perches only:
550	    // a dir with info.json counts; a dir without it, and nested children, do not;
551	    // a missing owlery yields empty (tolerant).
552	    #[test]
553	    fn list_self_perch_ids_enumerates_bound_top_level() {
554	        let tmp = tempfile::tempdir().unwrap();
555	        let owlery = tmp.path().join("owlery");
556
557	        // Missing owlery → empty, no panic.
558	        assert!(list_self_perch_ids(&owlery).is_empty());
559
560	        // Two bound Self perches.
561	        for id in ["doyle", "ling"] {
562	            let p = owlery.join(id);
563	            std::fs::create_dir_all(&p).unwrap();
564	            std::fs::write(p.join("info.json"), "{}").unwrap();
565	        }
566	        // A dir with no info.json (not a bound perch).
567	        std::fs::create_dir_all(owlery.join("not-a-perch")).unwrap();
568	        // A nested child under doyle — must NOT be enumerated at top level.
569	        let nested = owlery.join("doyle").join("nested").join("doyle-psyche");
570	        std::fs::create_dir_all(&nested).unwrap();
571	        std::fs::write(nested.join("info.json"), "{}").unwrap();
572
573	        let mut ids = list_self_perch_ids(&owlery);
574	        ids.sort();
575	        assert_eq!(ids, vec!["doyle".to_string(), "ling".to_string()]);
576	    }
577
578	    // [unit->REQ-SHELL-1] shell perches resolve under the owner's shells/
579	    // namespace — distinct from nested/, invisible to the top-level Self scan.
580	    #[test]
581	    fn shell_perches_resolve_under_owner_shells_namespace() {
582	        let tmp = tempfile::tempdir().unwrap();
583	        let _g = EnvGuard::set(tmp.path());
584	        let owlery = tmp.path().join("owlery");
585
586	        let shell = owlery.join("doyle").join("shells").join("GameRobot-0");
587	        assert_eq!(resolve_shell_perch_path("doyle", "GameRobot-0"), shell);
588	        assert_eq!(
589	            resolve_shell_perch_path_in(&owlery, "doyle", "GameRobot-0"),
590	            shell
591	        );
592	        assert_eq!(
593	            resolve_shells_dir("doyle"),
594	            owlery.join("doyle").join("shells")
595	        );
596	        // Not the nested/ namespace, and never what resolve_perch_path yields.
597	        assert_ne!(
598	            shell,
599	            resolve_perch_path("GameRobot-0", ParentHint::Explicit("doyle"))
600	        );
601
602	        // A bound owner with a bound shell: the top-level scan sees only the owner.
603	        std::fs::create_dir_all(owlery.join("doyle")).unwrap();
604	        std::fs::write(owlery.join("doyle").join("info.json"), "{}").unwrap();
605	        std::fs::create_dir_all(&shell).unwrap();
606	        std::fs::write(shell.join("info.json"), "{}").unwrap();
607	        assert_eq!(list_self_perch_ids(&owlery), vec!["doyle".to_string()]);
608	    }
609
610	    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] spt_home is pure: honors SPT_HOME,
611	    // creates nothing.
612	    #[test]
613	    fn spt_home_is_pure_and_honors_env() {
614	        let tmp = tempfile::tempdir().unwrap();
615	        let target = tmp.path().join("does-not-exist-yet");
616	        let _g = EnvGuard::set(&target);
617	        assert_eq!(spt_home(), target);
618	        assert!(!target.exists(), "spt_home must not create the dir");
619	    }
620	}
621

//! Perch layout — the **single source of truth** for where a perch lives on
//! disk. Every reader and writer of a perch path routes through
//! [`resolve_perch_path`]; nothing composes perch paths by hand. If the layout
//! ever changes, exactly this one module changes (KNOWN-HAZARDS 6.1 / 1.5).
//!
//! Adapted from sister `perch_path.rs` + `owlery.rs` path primitives
//! (ADR-0001), with two clean-room changes for the spt-core model:
//! - **Taxonomy:** the sister's Spine/Touch perch kinds are gone (CONTEXT
//!   endpoint types are ReadyAgent/LiveAgent/Psyche/Worker/SptNode). A top-level
//!   agent perch (ReadyAgent or a LiveAgent's Self) classifies as [`PerchKind::Self_`].
//! - **Purity:** path composition performs NO filesystem I/O — every function
//!   returns a `PathBuf`. Directory creation is the writer's job (e.g. the spool
//!   `create_dir_all`s on open).
//!
//! Layout invariant:
//! - `Self_`            → flat: `owlery/<id>/`
//! - `Psyche` / `Worker`→ nested under parent: `owlery/<parent>/nested/<child>/`
//!
//! The single `nested/` namespace holds both Psyche and Worker children — no
//! divergent sibling layouts (the hazard this module exists to prevent).

use std::path::{Path, PathBuf};

/// Root of all spt-core on-disk state. Honors `$SPT_HOME`, else the platform
/// default (`%LOCALAPPDATA%\spt-core` on Windows, `$HOME/.spt-core` on Unix).
/// **Pure** — reads env, composes a path, creates nothing.
///
/// The default lives under `spt-core/`, **deliberately distinct from the modern
/// spt runtime's `spt/` home**, so a dev build never collides with a running
/// spt-plugin during the parity build-out. Once spt-core + the spt-plugin reach
/// full parity with modern spt this may simply stay `spt-core/` for good; the
/// override (`$SPT_HOME`) is the relocation knob either way.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn spt_home() -> PathBuf {
    if let Ok(over) = std::env::var("SPT_HOME") {
        if !over.is_empty() {
            return PathBuf::from(over);
        }
    }
    platform_default_spt_home()
}

#[cfg(windows)]
fn platform_default_spt_home() -> PathBuf {
    if let Ok(local) = std::env::var("LOCALAPPDATA") {
        if !local.is_empty() {
            return PathBuf::from(local).join("spt-core");
        }
    }
    let up =
        std::env::var("USERPROFILE").expect("LOCALAPPDATA or USERPROFILE must be set on Windows");
    PathBuf::from(up)
        .join("AppData")
        .join("Local")
        .join("spt-core")
}

#[cfg(unix)]
fn platform_default_spt_home() -> PathBuf {
    let home = std::env::var("HOME").expect("HOME must be set on Unix");
    PathBuf::from(home).join(".spt-core")
}

/// The owlery directory holding all perches: `<spt_home>/owlery/`.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn owlery_dir() -> PathBuf {
    spt_home().join("owlery")
}

/// The synced-mind context tree: `<spt_home>/tracked/` (`docs/STORAGE.md`).
/// A git-backed two-tier store (`crate::contextstore`) — **durable, synced
/// across nodes** (the backup/migrate set), physically separate from the
/// never-synced `perches/` runtime. Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn tracked_dir() -> PathBuf {
    spt_home().join("tracked")
}

/// Node-local **durable** identity/security material: `<spt_home>/identity/`
/// (`docs/STORAGE.md`). Holds `node.key`, the [`trust_dir`] store, and
/// [`subnet_file`]. **Never git-tracked, never synced** — node-bound secret
/// material (the node keypair + subnet TOTP seeds). Pure path composition.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn identity_dir() -> PathBuf {
    spt_home().join("identity")
}

/// The node's durable Ed25519 identity seed: `<spt_home>/identity/node.key`
/// (`docs/STORAGE.md`). The node's stable name on the wire (the iroh EndpointId
/// *is* this keypair's public key, D1/D4) — per-node secret material, never
/// git-tracked, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn node_key_file() -> PathBuf {
    identity_dir().join("node.key")
}

/// The subnet membership + seed file: `<spt_home>/identity/subnet.json`
/// (`docs/STORAGE.md`). One file holding *all* subnets the node belongs to
/// (multi-subnet — a node holds N seeds), in a `(subnet, user)`-ready shape.
/// Replicated via pairing/P2P, **not** git.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn subnet_file() -> PathBuf {
    identity_dir().join("subnet.json")
}

/// The member-roster file: `<spt_home>/identity/roster.json` (REQ-MESH-2,
/// ADR-0017). The subnet discovery directory — a node-level union-merge
/// grow-set of `(pubkey, label, machine_id, address, last-seen)` per member,
/// with per-pubkey tombstones. Node-local, replicated via the
/// pairing/seed-proof'd P2P channel, **never** git-tracked/synced (same class
/// as `subnet.json`/`peers.json`). NOT the seed.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE] [impl->REQ-MESH-2]
pub fn roster_file() -> PathBuf {
    identity_dir().join("roster.json")
}

/// The per-node monotonic-epoch counter file: `<spt_home>/identity/epoch`. A single
/// strictly-increasing `u64` serving both the registry lease (#8) and sync precedence
/// (#7) — never wall-clock. Node-local, never synced (each node owns its own counter).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn epoch_file() -> PathBuf {
    identity_dir().join("epoch")
}

/// The per-`(endpoint, subnet)` visibility + sync-membership config file:
/// `<spt_home>/identity/visibility.json` (ADR-0006 §5/§6, D3e). The owning node's
/// advertisement policy — node-local, never git-tracked/synced (other nodes see only
/// its *effect*: presence/absence in the subnet registry).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn visibility_file() -> PathBuf {
    identity_dir().join("visibility.json")
}

/// The subnet notification spool: `<spt_home>/identity/notifs.db` (ADR-0007, D8).
/// Node-level (a notif targets the *user in a subnet*, not a perch), subnet-tagged
/// rows; the *content* replicates across each subnet's nodes via the P2P feed
/// (like `subnet.json`'s material — never git).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn notifs_file() -> PathBuf {
    identity_dir().join("notifs.db")
}

/// The trust store directory: `<spt_home>/identity/trust/` — authorized peer
/// node pubkeys (TOFU, `docs/STORAGE.md`). Node-local, never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_dir() -> PathBuf {
    identity_dir().join("trust")
}

/// The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store
/// of authorized peer node pubkeys (R-PAIR-2). Node-local, never git-tracked/synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn trust_file() -> PathBuf {
    trust_dir().join("peers.json")
}

/// The endpoint access whitelist: `<spt_home>/identity/trust/access.json`
/// (ADR-0009, REQ-SEC-1). Security material next to the trust store —
/// node-local, never git-tracked/synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn access_file() -> PathBuf {
    trust_dir().join("access.json")
}

/// The recent-outbound correlation log:
/// `<spt_home>/identity/trust/recent-outbound.json` — the stateful-firewall
/// reply-correlation state the access gate reads (REQ-SEC-1). Node-local,
/// never synced.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn recent_outbound_file() -> PathBuf {
    trust_dir().join("recent-outbound.json")
}

/// The consent grant store: `<spt_home>/identity/trust/grants.json`
/// (CONTEXT §Consent & security gates, REQ-CONSENT-1). Security material next
/// to the trust store — node-local, never git-tracked/synced. Default-deny
/// (the access whitelist's opposite polarity).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn grants_file() -> PathBuf {
    trust_dir().join("grants.json")
}

/// The registered-adapter set: `<spt_home>/adapters/` (`docs/STORAGE.md`,
/// REQ-INSTALL-4) — one subdir per adapter (`record.toml` + a manifest copy
/// or pointer). Node-local capability (drive/launch), not subnet
/// advertisement.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn adapters_dir() -> PathBuf {
    spt_home().join("adapters")
}

/// The shells namespace under an owner's Self perch:
/// `owlery/<owner>/shells/` (`docs/STORAGE.md` §Shells in the layout, M5-D3a).
/// A third namespace beside flat-Self and `nested/` — shell instances are
/// **not** perch kinds: [`resolve_perch_path`] never resolves them, and
/// [`list_self_perch_ids`] never enumerates them (they sit *inside* an
/// owner's dir, invisible to the top-level scan by construction). Pure path
/// composition, no I/O.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
// [impl->REQ-SHELL-1]
pub fn resolve_shells_dir_in(owlery: &Path, owner: &str) -> PathBuf {
    owlery.join(owner).join("shells")
}

/// [`resolve_shells_dir_in`] against the canonical [`owlery_dir`].
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn resolve_shells_dir(owner: &str) -> PathBuf {
    resolve_shells_dir_in(&owlery_dir(), owner)
}

/// **The** shell-instance perch resolver:
/// `owlery/<owner>/shells/<adapter>-<n>/`. Every reader/writer of a shell
/// perch routes through here — same single-source rule as agent perches
/// (KNOWN-HAZARDS 6.1).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
// [impl->REQ-SHELL-1]
pub fn resolve_shell_perch_path_in(owlery: &Path, owner: &str, shell_id: &str) -> PathBuf {
    resolve_shells_dir_in(owlery, owner).join(shell_id)
}

/// [`resolve_shell_perch_path_in`] against the canonical [`owlery_dir`].
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn resolve_shell_perch_path(owner: &str, shell_id: &str) -> PathBuf {
    resolve_shell_perch_path_in(&owlery_dir(), owner, shell_id)
}

/// Classification of a perch id by its naming convention. Stable, pure, no I/O.
///
/// - `<x>-psyche` → [`PerchKind::Psyche`]
/// - `<x>-w{N}`   → [`PerchKind::Worker`] (`{N}` = one-or-more ASCII digits, `<x>` non-empty)
/// - anything else → [`PerchKind::Self_`]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PerchKind {
    /// Top-level agent perch (a ReadyAgent, or a LiveAgent's Self).
    Self_,
    /// A LiveAgent's Psyche companion perch.
    Psyche,
    /// A subagent perch under a parent LiveAgent.
    Worker,
}

impl PerchKind {
    /// Classify an id by suffix. Worker is checked FIRST (rightmost `-w{digits}`)
    /// so `dunsen-psyche-w1` classifies as `Worker`.
    // [impl->REQ-HAZARD-WORKER-PATH]
    pub fn classify(id: &str) -> PerchKind {
        if is_worker_id(id) {
            return PerchKind::Worker;
        }
        if id.ends_with("-psyche") && id.len() > "-psyche".len() {
            return PerchKind::Psyche;
        }
        PerchKind::Self_
    }

    /// True for kinds nested under a parent (Psyche, Worker); false for Self_.
    pub fn nests_under_parent(self) -> bool {
        matches!(self, PerchKind::Psyche | PerchKind::Worker)
    }
}

/// Worker-id rule: rightmost `-w` whose suffix is one-or-more digits, with a
/// non-empty name before it.
fn is_worker_id(id: &str) -> bool {
    if let Some(pos) = id.rfind("-w") {
        if pos == 0 {
            return false;
        }
        let suffix = &id[pos + 2..];
        !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
    } else {
        false
    }
}

/// How the resolver determines the parent id for nested kinds.
/// `Explicit` is preferred (caller knows the parent); `Infer` derives it by
/// stripping the kind suffix from the child id (legacy reader call sites).
#[derive(Debug, Clone)]
pub enum ParentHint<'a> {
    Explicit(&'a str),
    Infer,
}

/// **The** perch-directory resolver. Self_ → flat `owlery/<id>/`;
/// Psyche/Worker → `owlery/<parent>/nested/<child>/`. Panic-free: if `Infer`
/// cannot derive a parent (id shape doesn't match its kind — should not happen
/// since classify+infer agree), falls back to flat for safety.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
// [impl->REQ-HAZARD-WORKER-PATH]
pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path_in(&owlery_dir(), child_id, hint)
}

/// [`resolve_perch_path`] against an **explicit** owlery root rather than the
/// process-global [`owlery_dir`]. Same single-source layout logic — this is the
/// real resolver and [`resolve_perch_path`] is the canonical-root wrapper. Used
/// by callers that already hold an owlery root (e.g. the registry's stale-clean,
/// which resolves a row's perch to read its daemon-managed `status`) so they
/// resolve consistently with the owlery they operate on, not the global home.
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
// [impl->REQ-HAZARD-WORKER-PATH]
pub fn resolve_perch_path_in(owlery: &Path, child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    let kind = PerchKind::classify(child_id);
    if !kind.nests_under_parent() {
        return owlery.join(child_id);
    }
    let parent = match hint {
        ParentHint::Explicit(p) => p.to_string(),
        ParentHint::Infer => match infer_parent(child_id, kind) {
            Some(p) => p,
            None => return owlery.join(child_id), // defensive flat fallback
        },
    };
    owlery.join(parent).join("nested").join(child_id)
}

/// Derive the parent id of a nested child by stripping its kind suffix.
fn infer_parent(id: &str, kind: PerchKind) -> Option<String> {
    match kind {
        PerchKind::Psyche => id.strip_suffix("-psyche").map(str::to_string),
        PerchKind::Worker => {
            let pos = id.rfind("-w")?;
            if pos == 0 {
                return None;
            }
            let suffix = &id[pos + 2..];
            if suffix.is_empty() || !suffix.bytes().all(|b| b.is_ascii_digit()) {
                return None;
            }
            Some(id[..pos].to_string())
        }
        PerchKind::Self_ => None,
    }
}

/// `<resolved_perch>/info.json`.
pub fn resolve_info_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    info_file_at(&resolve_perch_path(child_id, hint))
}

/// `<resolved_perch>/spool.db` — the spool the path-aware spool surface opens.
pub fn resolve_spool_db(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("spool.db")
}

/// `<resolved_perch>/ready`.
pub fn resolve_ready_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("ready")
}

/// `<resolved_perch>/inbox`.
pub fn resolve_inbox_dir(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join("inbox")
}

/// The activity/idle sentinel filename within a perch dir (spt-core-owned; the
/// adapter never names it — MANIFEST §What is NOT in the manifest).
pub const IDLE_SENTINEL: &str = ".idle";
/// The echo-commune gate sentinel filename (`.more-done`-equiv; the idle
/// transition arms it). spt-core-owned.
pub const ECHO_GATE_SENTINEL: &str = ".more-done";

/// `<resolved_perch>/.idle` — the activity/idle sentinel.
pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join(IDLE_SENTINEL)
}

/// `<resolved_perch>/.more-done` — the echo-commune gate sentinel. The single
/// path source (6.1) shared by the `api` surface (which arms it) and the M2b
/// pulse (which reads + clears it).
pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join(ECHO_GATE_SENTINEL)
}

/// `<perch_path>/info.json` from an already-resolved path.
pub fn info_file_at(perch_path: &Path) -> PathBuf {
    perch_path.join("info.json")
}

/// Enumerate the **top-level `Self_` perch ids** in `owlery` — every immediate
/// subdirectory that carries an `info.json` (a bound perch). Pure dir scan,
/// tolerant: a missing/unreadable owlery, or an entry without `info.json`, is
/// silently skipped (never panics, never hard-fails). Nested Psyche/Worker
/// perches live under `<self>/nested/` and are deliberately excluded — the
/// consent-target resolver wants the user's *agent* sessions, not their hosted
/// children. Order is unspecified (the caller ranks by recency).
// [impl->REQ-HAZARD-SINGLE-PATH-SOURCE]
pub fn list_self_perch_ids(owlery: &Path) -> Vec<String> {
    let Ok(entries) = std::fs::read_dir(owlery) else {
        return Vec::new();
    };
    let mut ids = Vec::new();
    for entry in entries.flatten() {
        if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
            continue;
        }
        if !info_file_at(&entry.path()).is_file() {
            continue; // a dir without info.json is not a bound perch
        }
        if let Some(name) = entry.file_name().to_str() {
            ids.push(name.to_string());
        }
    }
    ids
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    struct EnvGuard {
        _lock: std::sync::MutexGuard<'static, ()>,
        prev: Option<String>,
    }
    impl EnvGuard {
        fn set(tmp: &Path) -> Self {
            let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
            let prev = std::env::var("SPT_HOME").ok();
            std::env::set_var("SPT_HOME", tmp);
            EnvGuard { _lock: lock, prev }
        }
    }
    impl Drop for EnvGuard {
        fn drop(&mut self) {
            match &self.prev {
                Some(v) => std::env::set_var("SPT_HOME", v),
                None => std::env::remove_var("SPT_HOME"),
            }
        }
    }

    // ---- classification ----

    // [unit->REQ-HAZARD-WORKER-PATH]
    #[test]
    fn classification_matrix() {
        assert_eq!(PerchKind::classify("dunsen"), PerchKind::Self_);
        assert_eq!(PerchKind::classify("dunsen-psyche"), PerchKind::Psyche);
        assert_eq!(PerchKind::classify("dunsen-w1"), PerchKind::Worker);
        assert_eq!(PerchKind::classify("dunsen-w42"), PerchKind::Worker);
        // Worker rule edges → Self_.
        assert_eq!(
            PerchKind::classify("-w1"),
            PerchKind::Self_,
            "no parent name"
        );
        assert_eq!(
            PerchKind::classify("dunsen-w"),
            PerchKind::Self_,
            "no digits"
        );
        assert_eq!(
            PerchKind::classify("dunsen-wa"),
            PerchKind::Self_,
            "non-digit"
        );
        // Rightmost -w wins.
        assert_eq!(PerchKind::classify("dunsen-psyche-w1"), PerchKind::Worker);
        // Dropped sister kinds are now plain Self_ (no Spine/Touch).
        assert_eq!(PerchKind::classify("dunsen-spine"), PerchKind::Self_);
        assert_eq!(PerchKind::classify("dunsen-touch"), PerchKind::Self_);
    }

    // ---- resolution: one resolver, no divergent layouts ----

    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Self_ is flat.
    #[test]
    fn resolve_self_is_flat() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let owlery = tmp.path().join("owlery");
        assert_eq!(
            resolve_perch_path("dunsen", ParentHint::Infer),
            owlery.join("dunsen")
        );
    }

    // [unit->REQ-HAZARD-WORKER-PATH] Psyche + Worker nest under the single
    // `nested/` namespace, via both Explicit and Infer.
    #[test]
    fn resolve_psyche_and_worker_nest_under_parent() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let owlery = tmp.path().join("owlery");

        let psyche_nested = owlery.join("dunsen").join("nested").join("dunsen-psyche");
        assert_eq!(
            resolve_perch_path("dunsen-psyche", ParentHint::Infer),
            psyche_nested
        );
        assert_eq!(
            resolve_perch_path("dunsen-psyche", ParentHint::Explicit("dunsen")),
            psyche_nested
        );

        let worker_nested = owlery.join("dunsen").join("nested").join("dunsen-w42");
        assert_eq!(
            resolve_perch_path("dunsen-w42", ParentHint::Infer),
            worker_nested
        );
        assert_eq!(
            resolve_perch_path("dunsen-w42", ParentHint::Explicit("dunsen")),
            worker_nested
        );
    }

    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] Explicit parent is trusted
    // mechanically (overrides what Infer would derive).
    #[test]
    fn explicit_parent_wins_over_infer() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let owlery = tmp.path().join("owlery");
        assert_eq!(
            resolve_perch_path("dunsen-psyche", ParentHint::Explicit("doyle")),
            owlery.join("doyle").join("nested").join("dunsen-psyche")
        );
    }

    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] child-file helpers all hang off the
    // one resolver — spool.db / info.json / ready / inbox share the perch root.
    #[test]
    fn child_files_route_through_one_resolver() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let root = resolve_perch_path("dunsen-w7", ParentHint::Explicit("dunsen"));
        assert_eq!(
            resolve_spool_db("dunsen-w7", ParentHint::Explicit("dunsen")),
            root.join("spool.db")
        );
        assert_eq!(
            resolve_info_file("dunsen-w7", ParentHint::Explicit("dunsen")),
            root.join("info.json")
        );
        assert_eq!(
            resolve_ready_file("dunsen-w7", ParentHint::Explicit("dunsen")),
            root.join("ready")
        );
        assert_eq!(
            resolve_inbox_dir("dunsen-w7", ParentHint::Explicit("dunsen")),
            root.join("inbox")
        );
    }

    // [unit->REQ-UPD-4] list_self_perch_ids returns top-level bound perches only:
    // a dir with info.json counts; a dir without it, and nested children, do not;
    // a missing owlery yields empty (tolerant).
    #[test]
    fn list_self_perch_ids_enumerates_bound_top_level() {
        let tmp = tempfile::tempdir().unwrap();
        let owlery = tmp.path().join("owlery");

        // Missing owlery → empty, no panic.
        assert!(list_self_perch_ids(&owlery).is_empty());

        // Two bound Self perches.
        for id in ["doyle", "ling"] {
            let p = owlery.join(id);
            std::fs::create_dir_all(&p).unwrap();
            std::fs::write(p.join("info.json"), "{}").unwrap();
        }
        // A dir with no info.json (not a bound perch).
        std::fs::create_dir_all(owlery.join("not-a-perch")).unwrap();
        // A nested child under doyle — must NOT be enumerated at top level.
        let nested = owlery.join("doyle").join("nested").join("doyle-psyche");
        std::fs::create_dir_all(&nested).unwrap();
        std::fs::write(nested.join("info.json"), "{}").unwrap();

        let mut ids = list_self_perch_ids(&owlery);
        ids.sort();
        assert_eq!(ids, vec!["doyle".to_string(), "ling".to_string()]);
    }

    // [unit->REQ-SHELL-1] shell perches resolve under the owner's shells/
    // namespace — distinct from nested/, invisible to the top-level Self scan.
    #[test]
    fn shell_perches_resolve_under_owner_shells_namespace() {
        let tmp = tempfile::tempdir().unwrap();
        let _g = EnvGuard::set(tmp.path());
        let owlery = tmp.path().join("owlery");

        let shell = owlery.join("doyle").join("shells").join("GameRobot-0");
        assert_eq!(resolve_shell_perch_path("doyle", "GameRobot-0"), shell);
        assert_eq!(
            resolve_shell_perch_path_in(&owlery, "doyle", "GameRobot-0"),
            shell
        );
        assert_eq!(
            resolve_shells_dir("doyle"),
            owlery.join("doyle").join("shells")
        );
        // Not the nested/ namespace, and never what resolve_perch_path yields.
        assert_ne!(
            shell,
            resolve_perch_path("GameRobot-0", ParentHint::Explicit("doyle"))
        );

        // A bound owner with a bound shell: the top-level scan sees only the owner.
        std::fs::create_dir_all(owlery.join("doyle")).unwrap();
        std::fs::write(owlery.join("doyle").join("info.json"), "{}").unwrap();
        std::fs::create_dir_all(&shell).unwrap();
        std::fs::write(shell.join("info.json"), "{}").unwrap();
        assert_eq!(list_self_perch_ids(&owlery), vec!["doyle".to_string()]);
    }

    // [unit->REQ-HAZARD-SINGLE-PATH-SOURCE] spt_home is pure: honors SPT_HOME,
    // creates nothing.
    #[test]
    fn spt_home_is_pure_and_honors_env() {
        let tmp = tempfile::tempdir().unwrap();
        let target = tmp.path().join("does-not-exist-yet");
        let _g = EnvGuard::set(&target);
        assert_eq!(spt_home(), target);
        assert!(!target.exists(), "spt_home must not create the dir");
    }
}


Scope exact usages per file before editing.


crates\spt-store\src\access.rs:74:    /// empty, same degrade stance as [`TrustStore`](crate::trust::TrustStore):
crates\xtask\src\main.rs:897:        let trust = spt_store::trust::TrustStore::load();
crates\xtask\src\main.rs:899:        for p in &trust.peers {
crates\spt-daemon\tests\dispatch.rs:37:use spt_store::trust::TrustStore;
crates\spt-daemon\tests\dispatch.rs:123:    let mut trust = TrustStore::load();
crates\spt-daemon\tests\dispatch.rs:213:// serve_update. The trust gate ran from B's canonical TrustStore.
crates\spt-store\src\perch.rs:111:/// as `subnet.json`/`peers.json`). NOT the seed.
crates\spt-store\src\perch.rs:150:/// The trusted-peers file: `<spt_home>/identity/trust/peers.json` — the TOFU store
crates\spt-store\src\perch.rs:153:pub fn trust_file() -> PathBuf {
crates\spt-store\src\perch.rs:154:    trust_dir().join("peers.json")
crates\spt\src\cli.rs:1305:        let trust = spt_store::trust::TrustStore::load();
crates\spt\src\cli.rs:2293:    trust: &spt_store::trust::TrustStore,
crates\spt\src\cli.rs:2306:            for peer in trust.peers_in(&s.name) {
crates\spt\src\cli.rs:2770:    let trust = spt_store::trust::TrustStore::load();
crates\spt\src\cli.rs:2822:        for peer in trust.peers_in(&sub.name) {
crates\spt\src\cli.rs:2904:    let mut trust = spt_store::trust::TrustStore::load();
crates\spt\src\cli.rs:2905:    let before = trust.peers.len();
crates\spt\src\cli.rs:2906:    trust.peers.retain(|p| p.subnet != name);
crates\spt\src\cli.rs:2907:    let dropped = before - trust.peers.len();
crates\spt\src\cli.rs:2931:fn prune_candidates(trust: &spt_store::trust::TrustStore, query: &str) -> Vec<String> {
crates\spt\src\cli.rs:2934:    for p in &trust.peers {
crates\spt\src\cli.rs:2961:    let mut trust = spt_store::trust::TrustStore::load();
crates\spt\src\cli.rs:2987:    let before = trust.peers.len();
crates\spt\src\cli.rs:2994:    trust.peers.retain(|p| p.pubkey_hex != hex);
crates\spt\src\cli.rs:2995:    let dropped = before - trust.peers.len();
crates\spt\src\cli.rs:6340:        let mut trust = spt_store::trust::TrustStore::default();
crates\spt\src\cli.rs:6518:        let trust = spt_store::trust::TrustStore::default();
crates\spt\src\cli.rs:6532:        let mut pinned = spt_store::trust::TrustStore::default();
crates\spt-store\src\roster.rs:21://! alongside [`subnet.json`](crate::subnet) and [`peers.json`](crate::trust)
crates\spt-store\src\roster.rs:142:    /// same stance as [`SubnetStore`](crate::subnet)/[`TrustStore`](crate::trust).
crates\spt-daemon\tests\twohost.rs:85:use spt_store::trust::TrustStore;
crates\spt-daemon\tests\twohost.rs:282:        trust: perch::trust_file(),
crates\spt-daemon\tests\twohost.rs:578:        || TrustStore::load().is_trusted(&rig.subnet, &rig.a_hex()),
crates\spt-daemon\tests\twohost.rs:834:        TrustStore::load().is_trusted(&rig.subnet, &rig.b_hex()),
crates\spt-daemon\src\dispatch.rs:73:use spt_store::trust::TrustStore;
crates\spt-daemon\src\dispatch.rs:326:            let trust = TrustStore::load();
crates\spt-net\src\net\mesh.rs:6://! `is_trusted` gate lacks). [`seedproof`] is the crypto that proof rests on:
crates\spt-daemon\tests\mesh.rs:11://! `trust.peers_in(subnet)` — *directly-paired* peers only, no relay. So when
crates\spt-daemon\tests\mesh.rs:53:use spt_store::trust::TrustStore;
crates\spt-daemon\tests\mesh.rs:186:        let mut trust = TrustStore::default();
crates\spt-daemon\tests\mesh.rs:190:        trust.save_to(&paths.trust).expect("save node trust");
crates\spt-daemon\tests\mesh.rs:268:/// node's *explicit* `paths.trust` (the push target), not here.
crates\spt-daemon\tests\mesh.rs:273:    let mut trust = TrustStore::load();
crates\spt-daemon\tests\mesh.rs:294:        trust: p.trust.clone(),
crates\spt-daemon\tests\mesh.rs:408:    let mut trust = TrustStore::default();
crates\spt-daemon\tests\mesh.rs:412:    trust.save_to(&node.paths.trust).expect("re-pin trust");
crates\spt-store\src\trust.rs:1://! `trust/peers.json` — the local trust store of authorized peer node pubkeys
crates\spt-store\src\trust.rs:15://!   it ([`TrustOutcome::Pinned`]); there is no CA — the ceremony itself is the trust
crates\spt-store\src\trust.rs:19://!   returns [`KeyChanged`] and does **not** silently overwrite — the caller surfaces
crates\spt-store\src\trust.rs:38://! The optional [`user`](TrustedPeer::user) field mirrors [`SubnetRecord`](crate::subnet::SubnetRecord):
crates\spt-store\src\trust.rs:48:use crate::perch::trust_file;
crates\spt-store\src\trust.rs:53:    /// A [`repin`](TrustStore::repin) named a `(subnet, label)` with no existing peer.
crates\spt-store\src\trust.rs:72:pub enum TrustOutcome {
crates\spt-store\src\trust.rs:79:    /// call [`repin`](TrustStore::repin). The danger case is a MITM presenting a fresh
crates\spt-store\src\trust.rs:81:    KeyChanged {
crates\spt-store\src\trust.rs:90:pub struct TrustedPeer {
crates\spt-store\src\trust.rs:106:/// The whole `peers.json` document: every peer this node trusts, across all subnets.
crates\spt-store\src\trust.rs:108:pub struct TrustStore {
crates\spt-store\src\trust.rs:111:    pub peers: Vec<TrustedPeer>,
crates\spt-store\src\trust.rs:114:impl TrustStore {
crates\spt-store\src\trust.rs:115:    /// Load from the canonical [`trust_file`]. A missing file is the empty store (an
crates\spt-store\src\trust.rs:117:    pub fn load() -> TrustStore {
crates\spt-store\src\trust.rs:118:        Self::load_from(&trust_file())
crates\spt-store\src\trust.rs:125:    pub fn load_from(path: &Path) -> TrustStore {
crates\spt-store\src\trust.rs:128:            Err(_) => TrustStore::default(),
crates\spt-store\src\trust.rs:132:    /// Persist to the canonical [`trust_file`], creating `identity/trust/` if needed.
crates\spt-store\src\trust.rs:134:        self.save_to(&trust_file())
crates\spt-store\src\trust.rs:151:    pub fn is_trusted(&self, subnet: &str, pubkey_hex: &str) -> bool {
crates\spt-store\src\trust.rs:158:    pub fn peers_in<'a>(&'a self, subnet: &'a str) -> impl Iterator<Item = &'a TrustedPeer> {
crates\spt-store\src\trust.rs:164:    /// Returns the [`TrustOutcome`]: [`Pinned`](TrustOutcome::Pinned) for a first use,
crates\spt-store\src\trust.rs:165:    /// [`AlreadyTrusted`](TrustOutcome::AlreadyTrusted) for an idempotent re-pair, or
crates\spt-store\src\trust.rs:166:    /// [`KeyChanged`](TrustOutcome::KeyChanged) if a peer under the same `(subnet,
crates\spt-store\src\trust.rs:177:    ) -> TrustOutcome {
crates\spt-store\src\trust.rs:180:        if self.is_trusted(subnet, pubkey_hex) {
crates\spt-store\src\trust.rs:181:            return TrustOutcome::AlreadyTrusted;
crates\spt-store\src\trust.rs:188:                return TrustOutcome::KeyChanged {
crates\spt-store\src\trust.rs:193:        self.peers.push(TrustedPeer {
crates\spt-store\src\trust.rs:200:        TrustOutcome::Pinned
crates\spt-store\src\trust.rs:205:    /// [`KeyChanged`](TrustOutcome::KeyChanged) outcome was surfaced and the operator
crates\spt-store\src\trust.rs:256:    fn first_use_pins_then_is_trusted_idempotently() {
crates\spt-store\src\trust.rs:257:        let mut store = TrustStore::default();
crates\spt-store\src\trust.rs:260:            TrustOutcome::Pinned
crates\spt-store\src\trust.rs:262:        assert!(store.is_trusted("home", "aa11"));
crates\spt-store\src\trust.rs:265:            TrustOutcome::AlreadyTrusted,
crates\spt-store\src\trust.rs:276:        let mut store = TrustStore::default();
crates\spt-store\src\trust.rs:278:        assert!(store.is_trusted("home", "aa11"));
crates\spt-store\src\trust.rs:280:            !store.is_trusted("work", "aa11"),
crates\spt-store\src\trust.rs:283:        assert!(!store.is_trusted("home", "bb22"), "unknown key untrusted");
crates\spt-store\src\trust.rs:287:    // DIFFERENT key returns KeyChanged carrying the existing key, and does NOT mutate
crates\spt-store\src\trust.rs:291:        let mut store = TrustStore::default();
crates\spt-store\src\trust.rs:296:            TrustOutcome::KeyChanged {
crates\spt-store\src\trust.rs:300:        assert!(store.is_trusted("home", "aa11"), "old key untouched");
crates\spt-store\src\trust.rs:302:            !store.is_trusted("home", "bb22"),
crates\spt-store\src\trust.rs:313:        let mut store = TrustStore::default();
crates\spt-store\src\trust.rs:316:        assert!(store.is_trusted("home", "bb22"), "new key trusted");
crates\spt-store\src\trust.rs:317:        assert!(!store.is_trusted("home", "aa11"), "old key replaced");
crates\spt-store\src\trust.rs:329:        let mut store = TrustStore::default();
crates\spt-store\src\trust.rs:332:            TrustOutcome::Pinned
crates\spt-store\src\trust.rs:336:            TrustOutcome::Pinned
crates\spt-store\src\trust.rs:339:        assert!(store.is_trusted("home", "aa11") && store.is_trusted("home", "bb22"));
crates\spt-store\src\trust.rs:347:        let mut store = TrustStore::default();
crates\spt-store\src\trust.rs:350:        assert!(!store.is_trusted("home", "aa11"));
crates\spt-store\src\trust.rs:361:        let mut store = TrustStore::default();
crates\spt-store\src\trust.rs:366:            TrustOutcome::Pinned
crates\spt-store\src\trust.rs:370:    // [unit->REQ-PAIR-2] peers_in projects a subnet's trusted set across a multi-subnet
crates\spt-store\src\trust.rs:373:    fn peers_in_filters_by_subnet() {
crates\spt-store\src\trust.rs:374:        let mut store = TrustStore::default();
crates\spt-store\src\trust.rs:378:        let home: Vec<_> = store.peers_in("home").map(|p| &p.pubkey_hex).collect();
crates\spt-store\src\trust.rs:381:        assert_eq!(store.peers_in("work").count(), 1);
crates\spt-store\src\trust.rs:390:        let path = dir.path().join("identity").join("trust").join("peers.json");
crates\spt-store\src\trust.rs:392:        let mut store = TrustStore::default();
crates\spt-store\src\trust.rs:398:        let reloaded = TrustStore::load_from(&path);
crates\spt-store\src\trust.rs:400:        assert!(reloaded.is_trusted("home", "dd44"), "repin durable");
crates\spt-store\src\trust.rs:401:        assert!(reloaded.is_trusted("work", "cc33"));
crates\spt-store\src\trust.rs:404:        assert!(TrustStore::load_from(&dir.path().join("nope.json"))
crates\spt-store\src\trust.rs:409:        assert!(TrustStore::load_from(&corrupt).peers.is_empty());
crates\spt-daemon\tests\propagate.rs:33:use spt_store::trust::TrustStore;
crates\spt-daemon\tests\propagate.rs:181:fn trust_for(peers: &[&str]) -> TrustStore {
crates\spt-daemon\tests\propagate.rs:182:    let mut t = TrustStore::default();
crates\spt-daemon\tests\propagate.rs:219:    trust: &TrustStore,
crates\spt-daemon\tests\propagate.rs:530:    trust: &TrustStore,
crates\spt-daemon\src\nethost.rs:96:    /// None`). The D5 gate swap reads this in place of `is_trusted(subnet, …)`;
crates\spt-daemon\src\nethost.rs:97:    /// at D2 it is cached but not yet consumed (the `is_trusted` gates still run
crates\spt-daemon\src\nethost.rs:679:    /// `is_trusted(subnet, origin)`; unused by any gate at D2.
crates\spt-daemon\tests\peerloop.rs:37:use spt_store::trust::TrustStore;
crates\spt-daemon\tests\peerloop.rs:135:    let mut trust = TrustStore::load();
crates\spt-daemon\tests\peerloop.rs:244:    let mut a_trust = TrustStore::default();
crates\spt-daemon\tests\peerloop.rs:246:    a_trust.save_to(&a_paths.trust).expect("save A trust");
crates\spt-daemon\tests\peerloop.rs:346:            trust: a_paths.trust.clone(),
crates\spt-daemon\src\notifsync.rs:44:    /// Load from the node's canonical stores (`subnet.json`, `trust/peers.json`).
crates\spt-daemon\src\pairhost.rs:39:use spt_store::trust::TrustStore;
crates\spt-daemon\src\pairhost.rs:78:    let mut trust = TrustStore::load();
crates\spt-daemon\src\pairhost.rs:113:                crate::registryhost::repair_evict_superseded(
crates\spt-daemon\src\pairhost.rs:136:                Ok(()) => eprintln!("PAIRED: joiner pinned ({:?})", outcome.trust),
crates\spt-daemon\src\pairhost.rs:308:    let mut trust = TrustStore::load();
crates\spt-daemon\src\propagate.rs:35:use spt_store::trust::TrustStore;
crates\spt-daemon\src\propagate.rs:111:    trust: &TrustStore,
crates\spt-daemon\src\propagate.rs:133:                            let trusted = trust.peers.iter().any(|p| p.pubkey_hex == origin_node);
crates\spt-daemon\src\propagate.rs:172:                            let trusted = trust.peers.iter().any(|p| p.pubkey_hex == origin_node);
crates\spt-daemon\src\peerloop.rs:57:use spt_store::trust::TrustStore;
crates\spt-daemon\src\peerloop.rs:118:    /// `trust/peers.json` ([`TrustStore::load_from`]).
crates\spt-daemon\src\peerloop.rs:163:            trust: identity.join("trust").join("peers.json"),
crates\spt-daemon\src\peerloop.rs:327:        let trust = TrustStore::load_from(&paths.trust);
crates\spt-daemon\src\peerloop.rs:364:            for peer in trust.peers_in(&sub.name) {
crates\spt-daemon\src\registryhost.rs:43:use spt_store::trust::TrustStore;
crates\spt-daemon\src\registryhost.rs:671:pub fn repair_evict_superseded(
crates\spt-daemon\src\registryhost.rs:672:    trust: &mut TrustStore,
crates\spt-daemon\src\registryhost.rs:798:    /// `TrustStore::record` pin; `is_member` keys on `(subnet, pubkey)`).
crates\spt-daemon\src\registryhost.rs:929:        let mut trust = TrustStore::default();
crates\spt-daemon\src\registryhost.rs:941:        repair_evict_superseded(&mut trust, &mut roster, "home", "new-key", &intro, &snap_dir);
crates\spt-daemon\src\registryhost.rs:944:            !trust.is_trusted("home", "old-key"),
crates\spt-daemon\src\registryhost.rs:948:            trust.is_trusted("worknet", "old-key"),
crates\spt-daemon\src\registryhost.rs:981:        let mut t2 = TrustStore::default();
crates\spt-daemon\src\registryhost.rs:989:        repair_evict_superseded(&mut t2, &mut r2, "home", "new-key", &other, &snap_dir);
crates\spt-daemon\src\registryhost.rs:990:        assert!(t2.is_trusted("home", "unrelated"));
crates\spt-net\src\net\pairing\wire.rs:7://! each side's [`TrustStore`]. It turns "two machines pair via one TOTP code" from a
crates\spt-net\src\net\pairing\wire.rs:44:use spt_store::trust::{TrustOutcome, TrustStore};
crates\spt-net\src\net\pairing\wire.rs:150:    pub trust: TrustOutcome,
crates\spt-net\src\net\pairing\wire.rs:166:/// On `Ok` the peer was confirmed and pinned (the returned [`TrustOutcome`]); the rate
crates\spt-net\src\net\pairing\wire.rs:178:    trust: &mut TrustStore,
crates\spt-net\src\net\pairing\wire.rs:281:    trust: &mut TrustStore,
crates\spt-net\src\net\pairing\wire.rs:282:) -> Result<TrustOutcome, PairWireError> {
crates\spt-net\src\net\pairing\wire.rs:368:/// the returned [`TrustOutcome`] is the TOFU decision.
crates\spt-net\src\net\pairing\wire.rs:385:    trust: &mut TrustStore,
crates\spt-net\src\net\pairing\wire.rs:391:) -> Result<TrustOutcome, PairWireError> {
crates\spt-net\src\net\pairing\wire.rs:916:    use spt_store::trust::TrustStore;
crates\spt-net\src\net\pairing\wire.rs:994:            let mut trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1011:        let mut init_trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1034:            TrustOutcome::Pinned,
crates\spt-net\src\net\pairing\wire.rs:1038:            resp_outcome.trust,
crates\spt-net\src\net\pairing\wire.rs:1039:            TrustOutcome::Pinned,
crates\spt-net\src\net\pairing\wire.rs:1060:        assert!(init_trust.is_trusted("home", &resp_pub.to_hex()));
crates\spt-net\src\net\pairing\wire.rs:1061:        assert!(resp_trust.is_trusted("home", &init_pub.to_hex()));
crates\spt-net\src\net\pairing\wire.rs:1063:            !init_trust.is_trusted("home", &init_pub.to_hex()),
crates\spt-net\src\net\pairing\wire.rs:1094:            let mut trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1106:        let mut init_trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1131:        assert!(init_trust.peers.is_empty(), "no trust on initiator");
crates\spt-net\src\net\pairing\wire.rs:1132:        assert!(resp_trust.peers.is_empty(), "no trust on responder");
crates\spt-net\src\net\pairing\wire.rs:1167:            let mut trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1177:        let mut init_trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1205:        assert!(init_trust.peers.is_empty());
crates\spt-net\src\net\pairing\wire.rs:1206:        assert!(resp_trust.peers.is_empty());
crates\spt-net\src\net\pairing\wire.rs:1231:            let mut trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1241:        let mut init_trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1266:        assert!(resp_trust.peers.is_empty());
crates\spt-net\src\net\pairing\wire.rs:1290:            let mut trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1300:        let mut init_trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1326:        assert!(resp_trust.peers.is_empty());
crates\spt-net\src\net\pairing\wire.rs:1357:            let mut trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1368:        let mut init_trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1416:            let mut trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1426:        let mut init_trust = TrustStore::default();
crates\spt-net\src\net\pairing\wire.rs:1455:            TrustOutcome::AlreadyTrusted,
crates\spt-net\src\net\pairing\wire.rs:1459:            resp_outcome.trust,
crates\spt-net\src\net\pairing\wire.rs:1460:            TrustOutcome::Pinned,
crates\spt-daemon\src\sync.rs:79:    /// Load from the node's canonical stores (`subnet.json`, `trust/peers.json`,


Large breaking refactor across ~12 files. Tracking with tasks, then execute bottom-up.


TaskCreate

spt-store: delete trust.rs, drop mod + trust_file

Delete crates/spt-store/src/trust.rs, remove `pub mod trust` from lib.rs, remove perch::trust_file (keep trust_dir for access/grants).


Task #1 created successfully: spt-store: delete trust.rs, drop mod + trust_file


spt-net wire.rs: strip trust from ceremony

Remove trust param + TrustOutcome from run_responder/run_initiator/responder_admitted; drop ResponderOutcome.trust field; migrate wire.rs tests to roster membership.


Task #2 created successfully: spt-net wire.rs: strip trust from ceremony


pairhost.rs: drop trust write

Remove TrustStore load/save/args in both legs; update run_responder/run_initiator calls; demote warn-on-change to machine_id notif at repair_evict_superseded point.


Task #3 created successfully: pairhost.rs: drop trust write

pairhost.rs: drop trust write


propagate.rs serve gate trust->roster

serve_update signature trust:&TrustStore -> roster:&RosterStore; both is_member-any checks; dispatch.rs:326 caller.


Task #4 created successfully: propagate.rs serve gate trust->roster

propagate.rs serve gate trust->roster


peerloop.rs THE WIDEN

push target trust.peers_in -> roster.members_in; reuse roster loaded for sync gate; drop PumpPaths.trust + TrustStore load.


Task #5 created successfully: peerloop.rs THE WIDEN

peerloop.rs THE WIDEN


registryhost.rs: repair_evict drop trust

repair_evict_superseded drop &mut TrustStore param (roster-only now); migrate its unit tests.


Task #6 created successfully: registryhost.rs: repair_evict drop trust


CLI cli.rs repoint to roster

subnet status/list/prune trust->roster (prune=local tombstone); 1305 load; render unit tests.


Task #7 created successfully: CLI cli.rs repoint to roster

CLI cli.rs repoint to roster


xtask + misc comment refs

xtask main.rs:897 trust dump -> roster; nethost/notifsync/sync/access doc comment refs.


Task #8 created successfully: xtask + misc comment refs

xtask + misc comment refs


Migrate daemon tests trust->roster

twohost.rs, propagate.rs, peerloop.rs, dispatch.rs tests: TrustStore -> RosterStore membership.


Task #9 created successfully: Migrate daemon tests trust->roster

Migrate daemon tests trust->roster


mesh.rs: un-ignore + regression assert

Un-ignore D0 staggered+star harness; migrate trust seeding to roster; add no-third-party-row origin==author assert.


Task #10 created successfully: mesh.rs: un-ignore + regression assert

mesh.rs: un-ignore + regression assert


Activate REQ-MESH-3 + complete REQ-MESH-5

traceable-reqs.toml: REQ-MESH-3 [impl,unit,int], REQ-MESH-5 [impl,unit]; run traceable-reqs check; clippy; daemon suite.


Task #11 created successfully: Activate REQ-MESH-3 + complete REQ-MESH-5


Read remaining cutover sites: registryhost evict + warn, cli sections, lib, mesh test.