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///
44/// On-wire tag is the postcard VARIANT INDEX (0..N in declaration order), not
45/// the `repr(u8)` discriminant — the explicit `= 255` is a C-ABI hint, never
46/// the serialized byte.
47#[non_exhaustive]
48#[repr(u8)]
49#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
50pub enum SpaceKind {
51    /// Flat list (e.g. BBS board).
52    Flat = 0,
53    /// Tree (e.g. nested comments).
54    Tree = 1,
55    /// Graph (e.g. follow graph).
56    Graph = 2,
57    /// Hashtag aggregation.
58    Hashtag = 3,
59    /// Per-actor feed.
60    ActorFeed = 4,
61    /// Shell-defined extension kind.
62    Extension {
63        /// Extension dispatch code.
64        type_code: TypeCode,
65    } = 255,
66}
67
68/// Visibility policy for Space contents.
69#[non_exhaustive]
70#[repr(u8)]
71#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
72pub enum Visibility {
73    /// World-readable.
74    Public = 0,
75    /// Restricted by L2 role-check.
76    RestrictedByRole = 1,
77    /// Readable by subscribers only.
78    SubscribersOnly = 2,
79    /// Private invitation list (see `SpaceMembership`).
80    PrivateInvite = 3,
81    /// End-to-end encrypted.
82    Encrypted = 4,
83}
84
85/// Space configuration Component — exactly one per Space (E-space-1).
86#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
87#[arkhe(type_code = 0x0003_0201, schema_version = 1)]
88pub struct SpaceConfig {
89    /// Wire-level schema version tag.
90    pub schema_version: u16,
91    /// Shell identity — immutable.
92    pub shell_id: ShellId,
93    /// URL-safe slug — unique within shell.
94    pub slug: BoundedString<32>,
95    /// Structural kind.
96    pub kind: SpaceKind,
97    /// Visibility policy.
98    pub visibility: Visibility,
99    /// Creating actor (must be in same shell — E-space-5).
100    pub creator: ActorId,
101    /// Parent Space in the DAG. Immutable after creation (E-space-7 / P5).
102    pub parent_space: Option<SpaceId>,
103    /// Creation tick.
104    pub created_tick: Tick,
105}
106
107/// Cached parent-chain depth — enables O(1) cycle / depth check (E-space-4).
108/// Monotone, computed from parent's `depth + 1` at spawn.
109#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
110#[arkhe(type_code = 0x0003_0202, schema_version = 1)]
111pub struct ParentChainDepth {
112    /// Wire-level schema version tag.
113    pub schema_version: u16,
114    /// Depth (0 = root, max [`MAX_SPACE_DEPTH`]).
115    pub depth: u8,
116}
117
118/// Membership list for `Visibility::PrivateInvite` Spaces (X3 DM support).
119#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
120#[arkhe(type_code = 0x0003_0203, schema_version = 1)]
121pub struct SpaceMembership {
122    /// Wire-level schema version tag.
123    pub schema_version: u16,
124    /// Permitted actor set — canonical `BTreeSet` ordering for deterministic
125    /// serialization.
126    pub members: BTreeSet<ActorId>,
127}
128
129/// Wire-format Space configuration MINUS the creating actor. The creator is
130/// NOT a wire field: the runtime injects the authenticated identity at the
131/// dispatch boundary, and [`CreateSpace::compute`] stamps it into the stored
132/// [`SpaceConfig`]. This is the structural close of the actor-substitution
133/// surface — there is no client-supplied `creator` to spoof.
134#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
135pub struct SpaceConfigDraft {
136    /// Wire-level schema version tag.
137    pub schema_version: u16,
138    /// Shell identity — immutable.
139    pub shell_id: ShellId,
140    /// URL-safe slug — unique within shell.
141    pub slug: BoundedString<32>,
142    /// Structural kind.
143    pub kind: SpaceKind,
144    /// Visibility policy.
145    pub visibility: Visibility,
146    /// Parent Space in the DAG. Immutable after creation (E-space-7 / P5).
147    pub parent_space: Option<SpaceId>,
148    /// Creation tick.
149    pub created_tick: Tick,
150}
151
152impl SpaceConfigDraft {
153    /// Promote a draft to a stored [`SpaceConfig`] by stamping the
154    /// authenticated `creator` — the single source of truth injected by the
155    /// runtime, never a wire field.
156    #[must_use]
157    fn into_config(self, creator: ActorId) -> SpaceConfig {
158        SpaceConfig {
159            schema_version: self.schema_version,
160            shell_id: self.shell_id,
161            slug: self.slug,
162            kind: self.kind,
163            visibility: self.visibility,
164            creator,
165            parent_space: self.parent_space,
166            created_tick: self.created_tick,
167        }
168    }
169}
170
171/// Spawn a fresh Space under `config`.
172///
173/// The payload carries no creating actor: the recorded creator is the
174/// authenticated identity the runtime injects via
175/// [`ActionContext::acting_actor`](crate::context::ActionContext::acting_actor),
176/// so it cannot be substituted.
177#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
178#[arkhe(type_code = 0x0001_0201, schema_version = 1, band = 1)]
179pub struct CreateSpace {
180    /// Wire-level schema version tag.
181    pub schema_version: u16,
182    /// Initial configuration minus the creating actor (injected at dispatch).
183    pub config: SpaceConfigDraft,
184}
185
186impl ActionCompute for CreateSpace {
187    #[arkhe_pure]
188    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
189        // Single source of truth: the creating actor is the authenticated
190        // identity the runtime injected at dispatch — never a wire field.
191        // A user-scoped action with no injected actor cannot proceed.
192        let creator = ctx.acting_actor().ok_or(ActionError::AuthorizationFailed(
193            "space requires an authenticated actor",
194        ))?;
195
196        // E-user-3 C3 MC — refuse Action when the creator's backing user is
197        // already in `GdprStatus::ErasurePending`. `GdprEraseUser` owns the
198        // write that sets that pointer (its own `UserGdprState` component).
199        ctx.ensure_actor_eligible(creator, ctx.tick())?;
200
201        // E-space-4 MC — parent chain depth check. A parent reference that
202        // would push the child past `MAX_SPACE_DEPTH` is rejected; a None
203        // parent roots at depth 0. The `ParentChainDepth` O(1) cache is
204        // read from the attached `InstanceView` (E8 invariant).
205        let child_depth: u8 = match self.config.parent_space {
206            Some(parent_id) => {
207                let parent_depth = ctx
208                    .read::<ParentChainDepth>(parent_id.get())?
209                    .ok_or(ActionError::InvalidInput("parent space not found"))?;
210                let next = parent_depth.depth.saturating_add(1);
211                if next > MAX_SPACE_DEPTH {
212                    return Err(ActionError::InvalidInput("space depth exceeded"));
213                }
214                next
215            }
216            None => 0,
217        };
218
219        // Stamp the injected creator into the stored config (authenticated
220        // creator), then spawn + attach.
221        let config = self.config.clone().into_config(creator);
222        let space_entity = ctx.spawn_entity_for::<SpaceConfig>()?;
223        ctx.set_component(space_entity, &config)?;
224        ctx.set_component(
225            space_entity,
226            &ParentChainDepth {
227                schema_version: 1,
228                depth: child_depth,
229            },
230        )?;
231        Ok(())
232    }
233}
234
235/// Maximum parent-chain depth (invariant E-space-4). Deeper trees reject with
236/// `DepthExceeded`.
237pub const MAX_SPACE_DEPTH: u8 = 64;
238
239#[cfg(test)]
240#[allow(clippy::unwrap_used, clippy::expect_used)]
241mod tests {
242    use super::*;
243    use crate::action::ArkheAction;
244    use crate::component::ArkheComponent;
245
246    fn ent(v: u64) -> EntityId {
247        EntityId::new(v).unwrap()
248    }
249
250    #[test]
251    fn space_config_serde_roundtrip_postcard() {
252        let cfg = SpaceConfig {
253            schema_version: 1,
254            shell_id: ShellId([0x01; 16]),
255            slug: BoundedString::<32>::new("general").unwrap(),
256            kind: SpaceKind::Tree,
257            visibility: Visibility::Public,
258            creator: ActorId::new(ent(42)),
259            parent_space: None,
260            created_tick: Tick(0),
261        };
262        let bytes = postcard::to_stdvec(&cfg).unwrap();
263        let back: SpaceConfig = postcard::from_bytes(&bytes).unwrap();
264        assert_eq!(cfg, back);
265    }
266
267    #[test]
268    fn space_membership_preserves_canonical_order() {
269        let mut set = BTreeSet::new();
270        set.insert(ActorId::new(ent(3)));
271        set.insert(ActorId::new(ent(1)));
272        set.insert(ActorId::new(ent(2)));
273        let m = SpaceMembership {
274            schema_version: 1,
275            members: set,
276        };
277        let serialized_once = postcard::to_stdvec(&m).unwrap();
278
279        let mut set2 = BTreeSet::new();
280        set2.insert(ActorId::new(ent(2)));
281        set2.insert(ActorId::new(ent(1)));
282        set2.insert(ActorId::new(ent(3)));
283        let m2 = SpaceMembership {
284            schema_version: 1,
285            members: set2,
286        };
287        assert_eq!(serialized_once, postcard::to_stdvec(&m2).unwrap());
288    }
289
290    #[test]
291    fn space_config_action_type_codes() {
292        assert_eq!(SpaceConfig::TYPE_CODE, 0x0003_0201);
293        assert_eq!(ParentChainDepth::TYPE_CODE, 0x0003_0202);
294        assert_eq!(SpaceMembership::TYPE_CODE, 0x0003_0203);
295        assert_eq!(CreateSpace::TYPE_CODE, 0x0001_0201);
296        assert_eq!(CreateSpace::BAND, 1);
297    }
298
299    #[test]
300    fn max_space_depth_is_sixty_four() {
301        assert_eq!(MAX_SPACE_DEPTH, 64);
302    }
303
304    fn draft() -> SpaceConfigDraft {
305        SpaceConfigDraft {
306            schema_version: 1,
307            shell_id: ShellId([0x01; 16]),
308            slug: BoundedString::<32>::new("general").unwrap(),
309            kind: SpaceKind::Flat,
310            visibility: Visibility::Public,
311            parent_space: None,
312            created_tick: Tick(0),
313        }
314    }
315
316    #[test]
317    fn create_space_records_injected_creator_not_a_wire_field() {
318        use crate::action::ActionCompute;
319        use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
320
321        let injected = ActorId::new(ent(0xC1));
322        let act = CreateSpace {
323            schema_version: 1,
324            config: draft(),
325        };
326        let mut c = ActionContext::new(
327            [0u8; 32],
328            InstanceId::new(1).unwrap(),
329            Tick(7),
330            Principal::System,
331            CapabilityMask::SYSTEM,
332        )
333        .with_actor(Some(injected));
334        act.compute(&mut c).expect("injected creator → compute ok");
335        let recorded = c.ops().iter().find_map(|op| match op {
336            arkhe_kernel::state::Op::SetComponent {
337                type_code, bytes, ..
338            } if *type_code == TypeCode(SpaceConfig::TYPE_CODE) => {
339                postcard::from_bytes::<SpaceConfig>(bytes).ok()
340            }
341            _ => None,
342        });
343        assert_eq!(
344            recorded.expect("config present").creator,
345            injected,
346            "recorded creator must equal the injected acting actor",
347        );
348    }
349
350    #[test]
351    fn create_space_without_injected_actor_rejects() {
352        use crate::action::ActionCompute;
353        use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
354
355        let act = CreateSpace {
356            schema_version: 1,
357            config: draft(),
358        };
359        let mut c = ActionContext::new(
360            [0u8; 32],
361            InstanceId::new(1).unwrap(),
362            Tick(7),
363            Principal::System,
364            CapabilityMask::SYSTEM,
365        );
366        let err = act
367            .compute(&mut c)
368            .expect_err("no injected actor must reject");
369        assert!(matches!(err, ActionError::AuthorizationFailed(_)));
370        assert!(c.ops().is_empty(), "no Ops on rejection");
371    }
372}