Skip to main content

arkhe_forge_core/
space.rs

1//! Space primitive — container / scope.
2
3use std::collections::BTreeSet;
4
5use arkhe_kernel::abi::{EntityId, Tick, TypeCode};
6use serde::{Deserialize, Serialize};
7
8use crate::action::ActionCompute;
9use crate::actor::ActorId;
10use crate::brand::ShellId;
11use crate::component::BoundedString;
12use crate::context::{ActionContext, ActionError};
13use crate::ArkheAction;
14use crate::ArkheComponent;
15// E14.L1-Deny enforcement on Action::compute.
16use crate::arkhe_pure;
17
18/// Opaque handle into the runtime Space namespace.
19#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct SpaceId(EntityId);
22
23impl SpaceId {
24    /// Construct a `SpaceId` from a runtime-allocated `EntityId`. Callers
25    /// must hold proof (spawn event, admin scope, or test fixture) that the
26    /// id belongs to the Space 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/// Space structural kind. `Extension` is an escape hatch — shell manifest must
42/// register the `type_code` with a `schema_hash` pin (E-space-6 / A15).
43#[non_exhaustive]
44#[repr(u8)]
45#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
46pub enum SpaceKind {
47    /// Flat list (e.g. BBS board).
48    Flat = 0,
49    /// Tree (e.g. nested comments).
50    Tree = 1,
51    /// Graph (e.g. follow graph).
52    Graph = 2,
53    /// Hashtag aggregation.
54    Hashtag = 3,
55    /// Per-actor feed.
56    ActorFeed = 4,
57    /// Shell-defined extension kind.
58    Extension {
59        /// Extension dispatch code.
60        type_code: TypeCode,
61    } = 255,
62}
63
64/// Visibility policy for Space contents.
65#[non_exhaustive]
66#[repr(u8)]
67#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
68pub enum Visibility {
69    /// World-readable.
70    Public = 0,
71    /// Restricted by L2 role-check.
72    RestrictedByRole = 1,
73    /// Readable by subscribers only.
74    SubscribersOnly = 2,
75    /// Private invitation list (see `SpaceMembership`).
76    PrivateInvite = 3,
77    /// End-to-end encrypted.
78    Encrypted = 4,
79}
80
81/// Space configuration Component — exactly one per Space (E-space-1).
82#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
83#[arkhe(type_code = 0x0003_0201, schema_version = 1)]
84pub struct SpaceConfig {
85    /// Wire-level schema version tag.
86    pub schema_version: u16,
87    /// Shell identity — immutable.
88    pub shell_id: ShellId,
89    /// URL-safe slug — unique within shell.
90    pub slug: BoundedString<32>,
91    /// Structural kind.
92    pub kind: SpaceKind,
93    /// Visibility policy.
94    pub visibility: Visibility,
95    /// Creating actor (must be in same shell — E-space-5).
96    pub creator: ActorId,
97    /// Parent Space in the DAG. Immutable after creation (E-space-7 / P5).
98    pub parent_space: Option<SpaceId>,
99    /// Creation tick.
100    pub created_tick: Tick,
101}
102
103/// Cached parent-chain depth — enables O(1) cycle / depth check (E-space-4).
104/// Monotone, computed from parent's `depth + 1` at spawn.
105#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
106#[arkhe(type_code = 0x0003_0202, schema_version = 1)]
107pub struct ParentChainDepth {
108    /// Wire-level schema version tag.
109    pub schema_version: u16,
110    /// Depth (0 = root, max [`MAX_SPACE_DEPTH`]).
111    pub depth: u8,
112}
113
114/// Membership list for `Visibility::PrivateInvite` Spaces (X3 DM support).
115#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
116#[arkhe(type_code = 0x0003_0203, schema_version = 1)]
117pub struct SpaceMembership {
118    /// Wire-level schema version tag.
119    pub schema_version: u16,
120    /// Permitted actor set — canonical `BTreeSet` ordering for deterministic
121    /// serialization.
122    pub members: BTreeSet<ActorId>,
123}
124
125/// Spawn a fresh Space under `config`.
126#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
127#[arkhe(type_code = 0x0001_0201, schema_version = 1, band = 1)]
128pub struct CreateSpace {
129    /// Wire-level schema version tag.
130    pub schema_version: u16,
131    /// Initial configuration.
132    pub config: SpaceConfig,
133}
134
135impl ActionCompute for CreateSpace {
136    #[arkhe_pure]
137    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
138        // E-user-3 C3 MC — refuse Action when the creator's backing user is
139        // already in `GdprStatus::ErasurePending`. The cascade owns the only
140        // legal write path until completion.
141        ctx.ensure_actor_eligible(self.config.creator, ctx.tick())?;
142
143        // E-space-4 MC — parent chain depth check. A parent reference that
144        // would push the child past `MAX_SPACE_DEPTH` is rejected; a None
145        // parent roots at depth 0. The `ParentChainDepth` O(1) cache is
146        // read from the attached `InstanceView` (E8 invariant).
147        let child_depth: u8 = match self.config.parent_space {
148            Some(parent_id) => {
149                let parent_depth = ctx
150                    .read::<ParentChainDepth>(parent_id.get())?
151                    .ok_or(ActionError::InvalidInput("parent space not found"))?;
152                let next = parent_depth.depth.saturating_add(1);
153                if next > MAX_SPACE_DEPTH {
154                    return Err(ActionError::InvalidInput("space depth exceeded"));
155                }
156                next
157            }
158            None => 0,
159        };
160
161        let space_entity = ctx.spawn_entity_for::<SpaceConfig>()?;
162        ctx.set_component(space_entity, &self.config)?;
163        ctx.set_component(
164            space_entity,
165            &ParentChainDepth {
166                schema_version: 1,
167                depth: child_depth,
168            },
169        )?;
170        Ok(())
171    }
172}
173
174/// Maximum parent-chain depth (invariant E-space-4). Deeper trees reject with
175/// `DepthExceeded`.
176pub const MAX_SPACE_DEPTH: u8 = 64;
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used, clippy::expect_used)]
180mod tests {
181    use super::*;
182    use crate::action::ArkheAction;
183    use crate::component::ArkheComponent;
184
185    fn ent(v: u64) -> EntityId {
186        EntityId::new(v).unwrap()
187    }
188
189    #[test]
190    fn space_config_serde_roundtrip_postcard() {
191        let cfg = SpaceConfig {
192            schema_version: 1,
193            shell_id: ShellId([0x01; 16]),
194            slug: BoundedString::<32>::new("general").unwrap(),
195            kind: SpaceKind::Tree,
196            visibility: Visibility::Public,
197            creator: ActorId::new(ent(42)),
198            parent_space: None,
199            created_tick: Tick(0),
200        };
201        let bytes = postcard::to_stdvec(&cfg).unwrap();
202        let back: SpaceConfig = postcard::from_bytes(&bytes).unwrap();
203        assert_eq!(cfg, back);
204    }
205
206    #[test]
207    fn space_membership_preserves_canonical_order() {
208        let mut set = BTreeSet::new();
209        set.insert(ActorId::new(ent(3)));
210        set.insert(ActorId::new(ent(1)));
211        set.insert(ActorId::new(ent(2)));
212        let m = SpaceMembership {
213            schema_version: 1,
214            members: set,
215        };
216        let serialized_once = postcard::to_stdvec(&m).unwrap();
217
218        let mut set2 = BTreeSet::new();
219        set2.insert(ActorId::new(ent(2)));
220        set2.insert(ActorId::new(ent(1)));
221        set2.insert(ActorId::new(ent(3)));
222        let m2 = SpaceMembership {
223            schema_version: 1,
224            members: set2,
225        };
226        assert_eq!(serialized_once, postcard::to_stdvec(&m2).unwrap());
227    }
228
229    #[test]
230    fn space_config_action_type_codes() {
231        assert_eq!(SpaceConfig::TYPE_CODE, 0x0003_0201);
232        assert_eq!(ParentChainDepth::TYPE_CODE, 0x0003_0202);
233        assert_eq!(SpaceMembership::TYPE_CODE, 0x0003_0203);
234        assert_eq!(CreateSpace::TYPE_CODE, 0x0001_0201);
235        assert_eq!(CreateSpace::BAND, 1);
236    }
237
238    #[test]
239    fn max_space_depth_is_sixty_four() {
240        assert_eq!(MAX_SPACE_DEPTH, 64);
241    }
242}