cairn_mod/pds_admin/types.rs
1//! Shared types for the PDS-admin outbound bridge (§F23, #84,
2//! v1.7).
3//!
4//! v1.7 introduces one new shared type: [`Subject`], the
5//! identity carried by [`crate::pds_admin::PdsAdminBackend`]'s
6//! label methods (`apply_label` / `negate_label`). The other
7//! trait methods (account-level actions like `takedown_account`)
8//! take a bare `did: &str` because the backend's wire shape
9//! identifies accounts by DID alone; only the record-level
10//! label surface needs the (DID, AT-URI, CID) triple.
11//!
12//! # Why a new type rather than reusing
13//! [`crate::labels::emission::ActionForEmission`]
14//!
15//! `ActionForEmission` is the v1.5 label-emission projection —
16//! it carries action-stream context (`action_type`,
17//! `expires_at`, `reason_codes`) that doesn't belong on a
18//! label-target identity. The [`PdsAdminBackend`] trait is
19//! decoupled from cairn-mod's recordAction stream; backends can
20//! be exercised by future direct-CLI tools (#99) and
21//! cross-cutting tests, not just by the recorder. A minimal
22//! pds_admin-local [`Subject`] keeps the trait's contract
23//! independent of any one caller's projection.
24//!
25//! [`PdsAdminBackend`]: crate::pds_admin::PdsAdminBackend
26
27use serde::{Deserialize, Serialize};
28
29/// Subject of a label apply / negate call.
30///
31/// ATProto subjects have three coordinates:
32/// - **DID** (always required): the account's DID. For
33/// account-level labels this is the only relevant field.
34/// - **AT-URI** (optional): present when the label targets a
35/// specific record (e.g., `at://did:plc:.../app.bsky.feed.post/abc`).
36/// Absent for account-level labels.
37/// - **CID** (optional): present when the label pins a specific
38/// record version. Absent when the label targets all
39/// versions of a record OR the account.
40///
41/// The convention mirrors v1.5's
42/// [`crate::labels::emission::ActionForEmission`]'s embedded
43/// (subject_did, subject_uri, cid) triple, but without the
44/// action-stream context fields. v1.7 trait callers project
45/// from whatever domain shape they have (the recorder's
46/// `RecordActionRequest`, an `ActionForEmission`, or operator
47/// CLI input) into this minimal identity.
48///
49/// # Validation
50///
51/// Construction is unconditional — the type doesn't enforce
52/// that `did.starts_with("did:")` or that `at_uri` parses as
53/// AT-URI. v1.7 trait implementations validate at the wire
54/// boundary (the backend's HTTP request layer in #86–#90);
55/// pre-validation lives in the recorder's
56/// [`crate::writer`]'s `route_subject` helper for the
57/// recordAction path. The trait callers may call this type's
58/// constructors with already-validated inputs.
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
60pub struct Subject {
61 /// Account DID. Always present. Account-level labels target
62 /// this DID directly; record-level labels still attribute
63 /// to the parent account via this field for strike-state
64 /// rollup (the same convention `subject_actions.subject_did`
65 /// uses in v1.4-v1.6).
66 pub did: String,
67 /// AT-URI for record-level labels. `None` for account-level
68 /// labels.
69 pub at_uri: Option<String>,
70 /// CID for record-version-pinned labels. `None` for
71 /// "all versions of this record" or for account-level
72 /// labels.
73 pub cid: Option<String>,
74}
75
76impl Subject {
77 /// Construct a [`Subject`] from a DID with no record-level
78 /// or version-level qualification. Use this for
79 /// account-level label targets.
80 pub fn account(did: impl Into<String>) -> Self {
81 Self {
82 did: did.into(),
83 at_uri: None,
84 cid: None,
85 }
86 }
87
88 /// Construct a [`Subject`] for a record (versioned or not).
89 /// `at_uri` should be a full `at://did:.../collection/rkey`
90 /// URI; the parent repo DID is supplied separately so
91 /// callers don't have to re-parse the URI to recover it.
92 /// Pass `cid = None` to label all versions; pass
93 /// `cid = Some(...)` to pin a specific record version.
94 pub fn record(did: impl Into<String>, at_uri: impl Into<String>, cid: Option<String>) -> Self {
95 Self {
96 did: did.into(),
97 at_uri: Some(at_uri.into()),
98 cid,
99 }
100 }
101
102 /// `true` when the subject is account-level (no AT-URI).
103 /// Convenience for trait implementations that branch on
104 /// account-vs-record path.
105 pub fn is_account_level(&self) -> bool {
106 self.at_uri.is_none()
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn account_constructor_omits_uri_and_cid() {
116 let s = Subject::account("did:plc:abc");
117 assert_eq!(s.did, "did:plc:abc");
118 assert!(s.at_uri.is_none());
119 assert!(s.cid.is_none());
120 assert!(s.is_account_level());
121 }
122
123 #[test]
124 fn record_constructor_with_cid_pins_version() {
125 let s = Subject::record(
126 "did:plc:abc",
127 "at://did:plc:abc/app.bsky.feed.post/xyz",
128 Some("bafy123".to_string()),
129 );
130 assert_eq!(s.did, "did:plc:abc");
131 assert_eq!(
132 s.at_uri.as_deref(),
133 Some("at://did:plc:abc/app.bsky.feed.post/xyz")
134 );
135 assert_eq!(s.cid.as_deref(), Some("bafy123"));
136 assert!(!s.is_account_level());
137 }
138
139 #[test]
140 fn record_constructor_without_cid_targets_all_versions() {
141 let s = Subject::record(
142 "did:plc:abc",
143 "at://did:plc:abc/app.bsky.feed.post/xyz",
144 None,
145 );
146 assert!(s.cid.is_none());
147 assert!(s.at_uri.is_some());
148 assert!(!s.is_account_level());
149 }
150
151 #[test]
152 fn subject_serde_roundtrips() {
153 let s = Subject::record(
154 "did:plc:abc",
155 "at://did:plc:abc/app.bsky.feed.post/xyz",
156 Some("bafy123".into()),
157 );
158 let json = serde_json::to_string(&s).unwrap();
159 let back: Subject = serde_json::from_str(&json).unwrap();
160 assert_eq!(s, back);
161 }
162}