Skip to main content

arkhe_forge_core/
actor.rs

1//! Actor primitive — per-shell activity subject.
2//!
3//! `Actor<'s, S>` carries two compile-time proofs: the shell brand `'s`
4//! (typed isolation) and the `ActorState` typestate (authentication status).
5//! Transition methods consume `self` so there is no way to forge a phantom
6//! state change.
7
8use core::marker::PhantomData;
9
10use arkhe_kernel::abi::{EntityId, Tick};
11use serde::{Deserialize, Serialize};
12
13use crate::brand::{ShellBrand, ShellId};
14use crate::component::BoundedString;
15use crate::user::UserId;
16use crate::ArkheComponent;
17
18/// Opaque handle into the runtime Actor namespace.
19#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct ActorId(EntityId);
22
23impl ActorId {
24    /// Construct an `ActorId` from a runtime-allocated `EntityId`. Callers
25    /// must hold proof (spawn event, admin scope, or test fixture) that the
26    /// id belongs to the Actor namespace — this constructor does not verify.
27    #[inline]
28    #[must_use]
29    pub fn new(id: EntityId) -> Self {
30        Self(id)
31    }
32
33    /// Underlying entity handle.
34    #[inline]
35    #[must_use]
36    pub fn get(self) -> EntityId {
37        self.0
38    }
39}
40
41/// Actor role family.
42#[non_exhaustive]
43#[repr(u8)]
44#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
45pub enum ActorKind {
46    /// Human operator.
47    Human = 0,
48    /// Automated bot with declared manifest.
49    Bot = 1,
50    /// System actor (moderation bot, migration worker).
51    System = 2,
52    /// Unauthenticated / pseudonymous.
53    Anonymous = 3,
54}
55
56mod state_seal {
57    /// Module-private sealed trait — prevents downstream `impl ActorState`.
58    pub trait Sealed {}
59}
60
61/// Sealed typestate marker for [`Actor`] authentication status.
62///
63/// Implementors are the three zero-variant marker types [`Anonymous`],
64/// [`Authenticated`], [`Suspended`]. Additional states cannot be added
65/// downstream (sealed).
66pub trait ActorState: state_seal::Sealed + 'static {
67    /// Canonical lower-case short name — used in metrics / logs.
68    const NAME: &'static str;
69}
70
71/// Typestate: actor has not (or not yet) authenticated.
72#[derive(Debug)]
73pub enum Anonymous {}
74/// Typestate: actor holds a verified `UserBinding`.
75#[derive(Debug)]
76pub enum Authenticated {}
77/// Typestate: actor is banned / quarantined — Actions reject at compute.
78#[derive(Debug)]
79pub enum Suspended {}
80
81impl state_seal::Sealed for Anonymous {}
82impl state_seal::Sealed for Authenticated {}
83impl state_seal::Sealed for Suspended {}
84
85impl ActorState for Anonymous {
86    const NAME: &'static str = "anonymous";
87}
88impl ActorState for Authenticated {
89    const NAME: &'static str = "authenticated";
90}
91impl ActorState for Suspended {
92    const NAME: &'static str = "suspended";
93}
94
95/// Shell-branded, typestate-tagged Actor handle.
96///
97/// The `'s` brand prevents cross-shell leakage at the type level; the
98/// [`ActorState`] phantom prevents calling authenticated-only API on an
99/// unauthenticated actor.
100pub struct Actor<'s, S: ActorState> {
101    brand: ShellBrand<'s>,
102    id: ActorId,
103    _state: PhantomData<fn() -> S>,
104}
105
106impl<'s, S: ActorState> Clone for Actor<'s, S> {
107    #[inline]
108    fn clone(&self) -> Self {
109        *self
110    }
111}
112impl<'s, S: ActorState> Copy for Actor<'s, S> {}
113
114impl<'s, S: ActorState> core::fmt::Debug for Actor<'s, S> {
115    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
116        f.debug_struct("Actor")
117            .field("id", &self.id)
118            .field("state", &S::NAME)
119            .finish()
120    }
121}
122
123impl<'s, S: ActorState> Actor<'s, S> {
124    /// Actor identity.
125    #[inline]
126    #[must_use]
127    pub fn id(self) -> ActorId {
128        self.id
129    }
130
131    /// Shell brand (zero-sized) — for passing through to other branded APIs.
132    #[inline]
133    #[must_use]
134    pub fn brand(self) -> ShellBrand<'s> {
135        self.brand
136    }
137}
138
139impl<'s> Actor<'s, Anonymous> {
140    /// Construct an unauthenticated Actor handle.
141    #[inline]
142    #[must_use]
143    pub fn new_anonymous(brand: ShellBrand<'s>, id: ActorId) -> Self {
144        Self {
145            brand,
146            id,
147            _state: PhantomData,
148        }
149    }
150
151    /// Consume the Anonymous handle and produce an Authenticated one. The
152    /// caller is expected to have verified `user_id` via the L2 credential
153    /// layer — this method only attaches the type-level marker.
154    #[inline]
155    #[must_use]
156    pub fn authenticate(self, _user_id: UserId) -> Actor<'s, Authenticated> {
157        Actor {
158            brand: self.brand,
159            id: self.id,
160            _state: PhantomData,
161        }
162    }
163}
164
165impl<'s> Actor<'s, Authenticated> {
166    /// Consume the Authenticated handle, producing a Suspended handle on
167    /// moderation action. Subsequent Actions by this actor reject at compute
168    /// time until the L2 suspension policy clears.
169    #[inline]
170    #[must_use]
171    pub fn suspend(self) -> Actor<'s, Suspended> {
172        Actor {
173            brand: self.brand,
174            id: self.id,
175            _state: PhantomData,
176        }
177    }
178}
179
180/// Actor profile Component — exactly one per Actor (invariant E-actor-1).
181#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
182#[arkhe(type_code = 0x0003_0101, schema_version = 1)]
183pub struct ActorProfile {
184    /// Wire-level schema version tag.
185    pub schema_version: u16,
186    /// Shell identity — immutable after creation (E-actor-5).
187    pub shell_id: ShellId,
188    /// Display handle — unique within `shell_id` (E-actor-3).
189    pub handle: BoundedString<32>,
190    /// Role family.
191    pub kind: ActorKind,
192    /// Tick of spawn.
193    pub created_tick: Tick,
194}
195
196/// Binding from Actor to the backing User — present iff the actor is
197/// Authenticated (invariant E-actor-2).
198#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
199#[arkhe(type_code = 0x0003_0102, schema_version = 1)]
200pub struct UserBinding {
201    /// Wire-level schema version tag.
202    pub schema_version: u16,
203    /// Backing user identity.
204    pub user_id: UserId,
205}
206
207#[cfg(test)]
208#[allow(clippy::unwrap_used, clippy::expect_used)]
209mod tests {
210    use super::*;
211    use crate::component::ArkheComponent;
212
213    fn ent(v: u64) -> EntityId {
214        EntityId::new(v).unwrap()
215    }
216
217    #[test]
218    fn actor_typestate_transitions_anonymous_authenticated_suspended() {
219        ShellBrand::run(|brand| {
220            let id = ActorId::new(ent(1));
221            let anon: Actor<'_, Anonymous> = Actor::new_anonymous(brand, id);
222            let user_id = UserId::new(ent(2));
223            let auth: Actor<'_, Authenticated> = anon.authenticate(user_id);
224            let susp: Actor<'_, Suspended> = auth.suspend();
225            assert_eq!(susp.id(), id);
226        });
227    }
228
229    #[test]
230    fn actor_state_names_are_distinct() {
231        assert_eq!(Anonymous::NAME, "anonymous");
232        assert_eq!(Authenticated::NAME, "authenticated");
233        assert_eq!(Suspended::NAME, "suspended");
234    }
235
236    #[test]
237    fn actor_profile_serde_roundtrip_postcard() {
238        let p = ActorProfile {
239            schema_version: 1,
240            shell_id: ShellId([0xAB; 16]),
241            handle: BoundedString::<32>::new("alice").unwrap(),
242            kind: ActorKind::Human,
243            created_tick: Tick(100),
244        };
245        let bytes = postcard::to_stdvec(&p).unwrap();
246        let back: ActorProfile = postcard::from_bytes(&bytes).unwrap();
247        assert_eq!(p, back);
248    }
249
250    #[test]
251    fn actor_profile_exposes_type_code_and_schema_version() {
252        assert_eq!(ActorProfile::TYPE_CODE, 0x0003_0101);
253        assert_eq!(ActorProfile::SCHEMA_VERSION, 1);
254    }
255}