Skip to main content

keepsake/
command.rs

1//! Typed lifecycle commands.
2
3use std::collections::BTreeMap;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::error::Result;
10use crate::model::{ActorRef, KeepsakeId, RelationId, RelationSpec, SubjectRef};
11
12/// Metadata attached to a command for audit and observation.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct CommandContext {
15    /// Actor responsible for the command.
16    pub actor: ActorRef,
17    /// Optional idempotency key supplied by the application.
18    pub idempotency_key: Option<String>,
19    /// Opaque application context.
20    pub metadata: BTreeMap<String, String>,
21}
22
23impl CommandContext {
24    /// Creates a command context for an actor.
25    #[must_use]
26    pub const fn new(actor: ActorRef) -> Self {
27        Self {
28            actor,
29            idempotency_key: None,
30            metadata: BTreeMap::new(),
31        }
32    }
33
34    /// Adds an idempotency key.
35    #[must_use]
36    pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
37        self.idempotency_key = Some(key.into());
38        self
39    }
40
41    /// Adds an opaque application metadata attribute.
42    #[must_use]
43    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
44        self.metadata.insert(key.into(), value.into());
45        self
46    }
47
48    /// Validates the command context.
49    pub fn validate(&self) -> Result<()> {
50        self.actor.validate()
51    }
52}
53
54/// Applies a relation to a subject.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct ApplyKeepsake {
57    /// Caller-supplied keepsake id.
58    pub id: KeepsakeId,
59    /// Subject to receive the relation.
60    pub subject: SubjectRef,
61    /// Relation definition id.
62    pub relation_id: RelationId,
63    /// Command timestamp.
64    pub at: DateTime<Utc>,
65    /// Opaque application metadata.
66    pub metadata: BTreeMap<String, String>,
67    /// Audit context.
68    pub context: CommandContext,
69}
70
71impl ApplyKeepsake {
72    /// Creates an apply command with a generated id.
73    #[must_use]
74    pub fn new(
75        subject: SubjectRef,
76        relation_id: RelationId,
77        at: DateTime<Utc>,
78        context: CommandContext,
79    ) -> Self {
80        Self {
81            id: Uuid::now_v7(),
82            subject,
83            relation_id,
84            at,
85            metadata: BTreeMap::new(),
86            context,
87        }
88    }
89
90    /// Creates an apply command for a typed relation spec.
91    #[must_use]
92    pub fn for_spec<Spec>(subject: SubjectRef, at: DateTime<Utc>, context: CommandContext) -> Self
93    where
94        Spec: RelationSpec,
95    {
96        Self::new(subject, Spec::ID, at, context)
97    }
98
99    /// Adds opaque application metadata.
100    #[must_use]
101    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
102        self.metadata.insert(key.into(), value.into());
103        self
104    }
105}
106
107/// Revokes an active keepsake.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct RevokeKeepsake {
110    /// Keepsake id.
111    pub keepsake_id: KeepsakeId,
112    /// Command timestamp.
113    pub at: DateTime<Utc>,
114    /// Audit context.
115    pub context: CommandContext,
116}
117
118impl RevokeKeepsake {
119    /// Creates a revoke command.
120    #[must_use]
121    pub const fn new(keepsake_id: KeepsakeId, at: DateTime<Utc>, context: CommandContext) -> Self {
122        Self {
123            keepsake_id,
124            at,
125            context,
126        }
127    }
128}
129
130/// Revokes the active keepsake for a subject and relation pair.
131///
132/// This addresses callers that know the `(subject, relation)` pair but not the
133/// keepsake id, which is the natural shape for relation-oriented access checks.
134/// The active uniqueness invariant guarantees at most one matching keepsake.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136pub struct RevokeBySubject {
137    /// Subject holding the relation.
138    pub subject: SubjectRef,
139    /// Relation definition id.
140    pub relation_id: RelationId,
141    /// Command timestamp.
142    pub at: DateTime<Utc>,
143    /// Audit context.
144    pub context: CommandContext,
145}
146
147impl RevokeBySubject {
148    /// Creates a revoke-by-subject command.
149    #[must_use]
150    pub const fn new(
151        subject: SubjectRef,
152        relation_id: RelationId,
153        at: DateTime<Utc>,
154        context: CommandContext,
155    ) -> Self {
156        Self {
157            subject,
158            relation_id,
159            at,
160            context,
161        }
162    }
163
164    /// Creates a revoke-by-subject command for a typed relation spec.
165    #[must_use]
166    pub const fn for_spec<Spec>(
167        subject: SubjectRef,
168        at: DateTime<Utc>,
169        context: CommandContext,
170    ) -> Self
171    where
172        Spec: RelationSpec,
173    {
174        Self::new(subject, Spec::ID, at, context)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use chrono::Utc;
181    use uuid::Uuid;
182
183    use super::*;
184    use crate::model::{ActorRef, StaticRelationKey, SubjectRef};
185    use crate::{ExpiryPolicy, RelationSpec};
186
187    struct TrustedTag;
188
189    impl RelationSpec for TrustedTag {
190        const ID: Uuid = Uuid::from_u128(1);
191        const KEY: StaticRelationKey = StaticRelationKey::new("tag", "trusted");
192
193        fn expiry(_at: chrono::DateTime<chrono::Utc>) -> ExpiryPolicy {
194            ExpiryPolicy::ManualOnly
195        }
196    }
197
198    #[test]
199    fn command_context_builder_sets_idempotency_and_metadata() -> crate::Result<()> {
200        let context = CommandContext::new(ActorRef::new("user", "admin")?)
201            .with_idempotency_key("request-1")
202            .with_metadata("request_id", "req_123");
203
204        assert_eq!(context.actor, ActorRef::new("user", "admin")?);
205        assert_eq!(context.idempotency_key.as_deref(), Some("request-1"));
206        assert_eq!(
207            context.metadata.get("request_id").map(String::as_str),
208            Some("req_123")
209        );
210        Ok(())
211    }
212
213    #[test]
214    fn apply_builder_attaches_metadata() -> crate::Result<()> {
215        let command = ApplyKeepsake::new(
216            SubjectRef::new("account", "acct_123")?,
217            Uuid::nil(),
218            Utc::now(),
219            CommandContext::new(ActorRef::new("system", "worker")?),
220        )
221        .with_metadata("source", "support");
222
223        assert_eq!(
224            command.metadata.get("source").map(String::as_str),
225            Some("support")
226        );
227        Ok(())
228    }
229
230    #[test]
231    fn typed_apply_and_revoke_constructors_set_command_fields() -> crate::Result<()> {
232        let at = Utc::now();
233        let context = CommandContext::new(ActorRef::new("system", "worker")?);
234        let apply = ApplyKeepsake::for_spec::<TrustedTag>(
235            SubjectRef::new("account", "acct_123")?,
236            at,
237            context.clone(),
238        );
239
240        assert_eq!(apply.relation_id, TrustedTag::ID);
241        assert_eq!(apply.at, at);
242        assert_eq!(apply.context, context);
243
244        let revoke = RevokeKeepsake::new(apply.id, at, apply.context.clone());
245        assert_eq!(revoke.keepsake_id, apply.id);
246        assert_eq!(revoke.at, at);
247        assert_eq!(revoke.context, apply.context);
248        Ok(())
249    }
250
251    #[test]
252    fn revoke_by_subject_constructors_set_command_fields() -> crate::Result<()> {
253        let at = Utc::now();
254        let subject = SubjectRef::new("account", "acct_123")?;
255        let context = CommandContext::new(ActorRef::new("user", "moderator")?);
256
257        let by_id = RevokeBySubject::new(subject.clone(), Uuid::nil(), at, context.clone());
258        assert_eq!(by_id.subject, subject);
259        assert_eq!(by_id.relation_id, Uuid::nil());
260        assert_eq!(by_id.at, at);
261        assert_eq!(by_id.context, context);
262
263        let by_spec = RevokeBySubject::for_spec::<TrustedTag>(subject, at, context);
264        assert_eq!(by_spec.relation_id, TrustedTag::ID);
265        Ok(())
266    }
267}