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::action::{ActionCompute, ArkheAction as _};
14use crate::brand::{ShellBrand, ShellId};
15use crate::component::{ArkheComponent as _, BoundedString};
16use crate::context::{ensure_schema_version, ActionContext, ActionError};
17use crate::user::UserId;
18use crate::ArkheAction;
19use crate::ArkheComponent;
20// E14.L1-Deny enforcement on Action::compute.
21use crate::arkhe_pure;
22
23/// Opaque handle into the runtime Actor namespace.
24#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
25#[serde(transparent)]
26pub struct ActorId(EntityId);
27
28impl ActorId {
29    /// Construct an `ActorId` from a runtime-allocated `EntityId`. Callers
30    /// must hold proof (spawn event, admin scope, or test fixture) that the
31    /// id belongs to the Actor namespace — this constructor does not verify.
32    #[inline]
33    #[must_use]
34    pub fn new(id: EntityId) -> Self {
35        Self(id)
36    }
37
38    /// Underlying entity handle.
39    #[inline]
40    #[must_use]
41    pub fn get(self) -> EntityId {
42        self.0
43    }
44}
45
46/// Actor role family.
47#[non_exhaustive]
48#[repr(u8)]
49#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
50pub enum ActorKind {
51    /// Human operator.
52    Human = 0,
53    /// Automated bot with declared manifest.
54    Bot = 1,
55    /// System actor (moderation bot, migration worker).
56    System = 2,
57    /// Unauthenticated / pseudonymous.
58    Anonymous = 3,
59}
60
61mod state_seal {
62    /// Module-private sealed trait — prevents downstream `impl ActorState`.
63    pub trait Sealed {}
64}
65
66/// Sealed typestate marker for [`Actor`] authentication status.
67///
68/// Implementors are the three zero-variant marker types [`Anonymous`],
69/// [`Authenticated`], [`Suspended`]. Additional states cannot be added
70/// downstream (sealed).
71pub trait ActorState: state_seal::Sealed + 'static {
72    /// Canonical lower-case short name — used in metrics / logs.
73    const NAME: &'static str;
74}
75
76/// Typestate: actor has not (or not yet) authenticated.
77#[derive(Debug)]
78pub enum Anonymous {}
79/// Typestate: actor holds a verified `UserBinding`.
80#[derive(Debug)]
81pub enum Authenticated {}
82/// Typestate: actor is banned / quarantined — Actions reject at compute.
83#[derive(Debug)]
84pub enum Suspended {}
85
86impl state_seal::Sealed for Anonymous {}
87impl state_seal::Sealed for Authenticated {}
88impl state_seal::Sealed for Suspended {}
89
90impl ActorState for Anonymous {
91    const NAME: &'static str = "anonymous";
92}
93impl ActorState for Authenticated {
94    const NAME: &'static str = "authenticated";
95}
96impl ActorState for Suspended {
97    const NAME: &'static str = "suspended";
98}
99
100/// Shell-branded, typestate-tagged Actor handle.
101///
102/// The `'s` brand prevents cross-shell leakage at the type level; the
103/// [`ActorState`] phantom prevents calling authenticated-only API on an
104/// unauthenticated actor.
105pub struct Actor<'s, S: ActorState> {
106    brand: ShellBrand<'s>,
107    id: ActorId,
108    _state: PhantomData<fn() -> S>,
109}
110
111impl<'s, S: ActorState> Clone for Actor<'s, S> {
112    #[inline]
113    fn clone(&self) -> Self {
114        *self
115    }
116}
117impl<'s, S: ActorState> Copy for Actor<'s, S> {}
118
119impl<'s, S: ActorState> core::fmt::Debug for Actor<'s, S> {
120    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
121        f.debug_struct("Actor")
122            .field("id", &self.id)
123            .field("state", &S::NAME)
124            .finish()
125    }
126}
127
128impl<'s, S: ActorState> Actor<'s, S> {
129    /// Actor identity.
130    #[inline]
131    #[must_use]
132    pub fn id(self) -> ActorId {
133        self.id
134    }
135
136    /// Shell brand (zero-sized) — for passing through to other branded APIs.
137    #[inline]
138    #[must_use]
139    pub fn brand(self) -> ShellBrand<'s> {
140        self.brand
141    }
142}
143
144impl<'s> Actor<'s, Anonymous> {
145    /// Construct an unauthenticated Actor handle.
146    #[inline]
147    #[must_use]
148    pub fn new_anonymous(brand: ShellBrand<'s>, id: ActorId) -> Self {
149        Self {
150            brand,
151            id,
152            _state: PhantomData,
153        }
154    }
155
156    /// Consume the Anonymous handle and produce an Authenticated one. The
157    /// caller is expected to have verified `user_id` via the L2 credential
158    /// layer — this method only attaches the type-level marker.
159    #[inline]
160    #[must_use]
161    pub fn authenticate(self, _user_id: UserId) -> Actor<'s, Authenticated> {
162        Actor {
163            brand: self.brand,
164            id: self.id,
165            _state: PhantomData,
166        }
167    }
168}
169
170impl<'s> Actor<'s, Authenticated> {
171    /// Consume the Authenticated handle, producing a Suspended handle on
172    /// moderation action. Subsequent Actions by this actor reject at compute
173    /// time until the L2 suspension policy clears.
174    #[inline]
175    #[must_use]
176    pub fn suspend(self) -> Actor<'s, Suspended> {
177        Actor {
178            brand: self.brand,
179            id: self.id,
180            _state: PhantomData,
181        }
182    }
183}
184
185/// Actor profile Component — exactly one per Actor (invariant E-actor-1).
186#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
187#[arkhe(type_code = 0x0003_0101, schema_version = 1)]
188pub struct ActorProfile {
189    /// Wire-level schema version tag.
190    pub schema_version: u16,
191    /// Shell identity — immutable after creation (E-actor-5).
192    pub shell_id: ShellId,
193    /// Display handle — unique within `shell_id` (E-actor-3).
194    pub handle: BoundedString<32>,
195    /// Role family.
196    pub kind: ActorKind,
197    /// Tick of spawn.
198    pub created_tick: Tick,
199}
200
201/// Binding from Actor to the backing User — present iff the actor is
202/// Authenticated (invariant E-actor-2).
203#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
204#[arkhe(type_code = 0x0003_0102, schema_version = 1)]
205pub struct UserBinding {
206    /// Wire-level schema version tag.
207    pub schema_version: u16,
208    /// Backing user identity.
209    pub user_id: UserId,
210}
211
212/// Register a fresh `Actor` bound to an existing User — the production
213/// write that makes the GDPR admission gate
214/// ([`ActionContext::ensure_actor_eligible`]) live for this actor: the
215/// gate resolves actor → user through the [`UserBinding`] attached here,
216/// so an actor created without it has no resolvable user scope and the
217/// gate soft-passes it (system / anonymous actors).
218///
219/// Follows the spawn-then-set discipline of
220/// [`RegisterUser`](crate::user::RegisterUser): the actor entity is spawned
221/// first (the kernel ledger no-ops a `SetComponent` on a never-spawned
222/// entity), then `ActorProfile` (E-actor-1) and `UserBinding` (E-actor-2)
223/// are attached.
224///
225/// System-scoped with an explicit `user` target field: this action runs in
226/// the registration flow immediately after `RegisterUser`, when the actor
227/// credential does not exist yet — there is no authenticated actor to
228/// inject, so the integrator's registration path submits it as the system
229/// principal and names the freshly created user explicitly. A binding that
230/// names a never-registered user does not produce an ungateable actor: the
231/// admission gate fails closed on a resolved binding whose user has no
232/// `UserGdprState` ([`ActionError::UserLifecycleUnresolved`]).
233#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
234#[arkhe(type_code = 0x0001_0101, schema_version = 1, band = 1)]
235pub struct RegisterActor {
236    /// Wire-level schema version tag.
237    pub schema_version: u16,
238    /// Profile Component contents (E-actor-1 — exactly one per Actor).
239    pub profile: ActorProfile,
240    /// Backing user the new actor authenticates as (E-actor-2).
241    pub user: UserId,
242}
243
244impl ActionCompute for RegisterActor {
245    #[arkhe_pure]
246    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
247        // Validate-then-copy: wire schema versions are checked against the
248        // canonical constants before any other gate, so a stale or forged
249        // version never reaches the stored profile.
250        ensure_schema_version(Self::SCHEMA_VERSION, self.schema_version)?;
251        ensure_schema_version(ActorProfile::SCHEMA_VERSION, self.profile.schema_version)?;
252
253        // E-actor-3 — `(shell_id, handle)` uniqueness. Soft-passes when no
254        // index is bound, matching the other L1 index-backed gates.
255        if ctx
256            .actor_by_handle(self.profile.shell_id, &self.profile.handle)
257            .is_some()
258        {
259            return Err(ActionError::ActorHandleCollision {
260                shell_id: self.profile.shell_id,
261                handle: self.profile.handle.clone(),
262            });
263        }
264
265        let actor_entity = ctx.spawn_entity_for::<ActorProfile>()?;
266        ctx.set_component(actor_entity, &self.profile)?;
267        ctx.set_component(
268            actor_entity,
269            &UserBinding {
270                schema_version: UserBinding::SCHEMA_VERSION,
271                user_id: self.user,
272            },
273        )?;
274        Ok(())
275    }
276}
277
278#[cfg(test)]
279#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
280mod tests {
281    use super::*;
282    use crate::component::ArkheComponent;
283
284    fn ent(v: u64) -> EntityId {
285        EntityId::new(v).unwrap()
286    }
287
288    #[test]
289    fn actor_typestate_transitions_anonymous_authenticated_suspended() {
290        ShellBrand::run(|brand| {
291            let id = ActorId::new(ent(1));
292            let anon: Actor<'_, Anonymous> = Actor::new_anonymous(brand, id);
293            let user_id = UserId::new(ent(2));
294            let auth: Actor<'_, Authenticated> = anon.authenticate(user_id);
295            let susp: Actor<'_, Suspended> = auth.suspend();
296            assert_eq!(susp.id(), id);
297        });
298    }
299
300    #[test]
301    fn actor_state_names_are_distinct() {
302        assert_eq!(Anonymous::NAME, "anonymous");
303        assert_eq!(Authenticated::NAME, "authenticated");
304        assert_eq!(Suspended::NAME, "suspended");
305    }
306
307    #[test]
308    fn actor_profile_serde_roundtrip_postcard() {
309        let p = ActorProfile {
310            schema_version: 1,
311            shell_id: ShellId([0xAB; 16]),
312            handle: BoundedString::<32>::new("alice").unwrap(),
313            kind: ActorKind::Human,
314            created_tick: Tick(100),
315        };
316        let bytes = postcard::to_stdvec(&p).unwrap();
317        let back: ActorProfile = postcard::from_bytes(&bytes).unwrap();
318        assert_eq!(p, back);
319    }
320
321    #[test]
322    fn actor_profile_exposes_type_code_and_schema_version() {
323        assert_eq!(ActorProfile::TYPE_CODE, 0x0003_0101);
324        assert_eq!(ActorProfile::SCHEMA_VERSION, 1);
325    }
326
327    fn test_ctx() -> ActionContext<'static> {
328        use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
329        ActionContext::new(
330            [0u8; 32],
331            InstanceId::new(1).unwrap(),
332            Tick(7),
333            Principal::System,
334            CapabilityMask::SYSTEM,
335        )
336    }
337
338    fn register_actor(v: u64) -> RegisterActor {
339        RegisterActor {
340            schema_version: 1,
341            profile: ActorProfile {
342                schema_version: 1,
343                shell_id: ShellId([0xAB; 16]),
344                handle: BoundedString::<32>::new("alice").unwrap(),
345                kind: ActorKind::Human,
346                created_tick: Tick(7),
347            },
348            user: UserId::new(ent(v)),
349        }
350    }
351
352    #[test]
353    fn register_actor_spawns_actor_then_sets_profile_and_binding() {
354        use arkhe_kernel::abi::TypeCode;
355        use arkhe_kernel::state::Op;
356
357        let mut c = test_ctx();
358        register_actor(7).compute(&mut c).expect("compute ok");
359        let ops = c.drain_ops();
360        assert_eq!(ops.len(), 3, "spawn + ActorProfile + UserBinding");
361
362        let Op::SpawnEntity { id: actor_id, .. } = &ops[0] else {
363            panic!("op 0 must spawn the actor entity, got {:?}", ops[0]);
364        };
365        match &ops[1] {
366            Op::SetComponent {
367                entity, type_code, ..
368            } => {
369                assert_eq!(entity, actor_id, "profile lands on the spawned actor");
370                assert_eq!(*type_code, TypeCode(ActorProfile::TYPE_CODE));
371            }
372            other => panic!("expected SetComponent(ActorProfile), got {:?}", other),
373        }
374        match &ops[2] {
375            Op::SetComponent {
376                entity,
377                type_code,
378                bytes,
379                ..
380            } => {
381                assert_eq!(entity, actor_id, "binding lands on the spawned actor");
382                assert_eq!(*type_code, TypeCode(UserBinding::TYPE_CODE));
383                let binding: UserBinding = postcard::from_bytes(bytes).unwrap();
384                assert_eq!(binding.user_id, UserId::new(ent(7)));
385            }
386            other => panic!("expected SetComponent(UserBinding), got {:?}", other),
387        }
388    }
389
390    #[test]
391    fn register_actor_rejects_handle_collision_via_index() {
392        use crate::context::ActorHandleIndex;
393
394        struct OneOccupant {
395            shell: ShellId,
396            handle: BoundedString<32>,
397            holder: ActorId,
398        }
399        impl ActorHandleIndex for OneOccupant {
400            fn lookup(&self, shell: ShellId, handle: &BoundedString<32>) -> Option<ActorId> {
401                (shell == self.shell && *handle == self.handle).then_some(self.holder)
402            }
403        }
404
405        let act = register_actor(7);
406        let index = OneOccupant {
407            shell: act.profile.shell_id,
408            handle: act.profile.handle.clone(),
409            holder: ActorId::new(ent(99)),
410        };
411        let mut c = test_ctx().with_actor_handle_index(&index);
412        let err = act
413            .compute(&mut c)
414            .expect_err("occupied handle must reject");
415        match err {
416            ActionError::ActorHandleCollision { shell_id, handle } => {
417                assert_eq!(shell_id, act.profile.shell_id);
418                assert_eq!(handle, act.profile.handle);
419            }
420            other => panic!("expected ActorHandleCollision, got {:?}", other),
421        }
422        assert!(c.ops().is_empty(), "no Ops on rejection");
423    }
424
425    #[test]
426    fn register_actor_rejects_wire_schema_mismatch() {
427        let mut c = test_ctx();
428
429        let mut act = register_actor(7);
430        act.schema_version = 0xBEEF;
431        let err = act.compute(&mut c).expect_err("action field");
432        assert!(
433            matches!(
434                err,
435                ActionError::SchemaMismatch {
436                    expected: 1,
437                    got: 0xBEEF,
438                }
439            ),
440            "got {err:?}",
441        );
442        assert!(c.ops().is_empty(), "no Ops on rejection");
443
444        let mut act = register_actor(7);
445        act.profile.schema_version = 0xBEEF;
446        let err = act.compute(&mut c).expect_err("profile field");
447        assert!(
448            matches!(
449                err,
450                ActionError::SchemaMismatch {
451                    expected: 1,
452                    got: 0xBEEF,
453                }
454            ),
455            "got {err:?}",
456        );
457        assert!(c.ops().is_empty(), "no Ops on rejection");
458    }
459
460    #[test]
461    fn register_actor_exposes_trait_consts() {
462        use crate::action::ArkheAction;
463        assert_eq!(RegisterActor::TYPE_CODE, 0x0001_0101);
464        assert_eq!(RegisterActor::SCHEMA_VERSION, 1);
465        assert_eq!(RegisterActor::BAND, 1);
466        const { assert!(!RegisterActor::IDEMPOTENT) };
467    }
468}