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, ArkheAction as _};
9use crate::actor::ActorId;
10use crate::brand::ShellId;
11use crate::component::{ArkheComponent as _, BoundedString};
12use crate::context::{ensure_schema_version, 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        // Validate-then-copy: wire schema versions are checked against the
190        // canonical constants before any other gate, so a stale or forged
191        // version never reaches the stored config.
192        ensure_schema_version(Self::SCHEMA_VERSION, self.schema_version)?;
193        ensure_schema_version(SpaceConfig::SCHEMA_VERSION, self.config.schema_version)?;
194
195        // Single source of truth: the creating actor is the authenticated
196        // identity the runtime injected at dispatch — never a wire field.
197        // A user-scoped action with no injected actor cannot proceed.
198        let creator = ctx.acting_actor().ok_or(ActionError::AuthorizationFailed(
199            "space requires an authenticated actor",
200        ))?;
201
202        // E-user-3 C3 MC — refuse Action when the creator's backing user is
203        // already in `GdprStatus::ErasurePending`. `GdprEraseUser` owns the
204        // write that sets that pointer (its own `UserGdprState` component).
205        ctx.ensure_actor_eligible(creator, ctx.tick())?;
206
207        // E-space-4 MC — parent chain depth check. A parent reference that
208        // would push the child past `MAX_SPACE_DEPTH` is rejected; a None
209        // parent roots at depth 0. The `ParentChainDepth` O(1) cache is
210        // read from the attached `InstanceView` (E8 invariant).
211        let child_depth: u8 = match self.config.parent_space {
212            Some(parent_id) => {
213                let parent_depth = ctx
214                    .read::<ParentChainDepth>(parent_id.get())?
215                    .ok_or(ActionError::InvalidInput("parent space not found"))?;
216                let next = parent_depth.depth.saturating_add(1);
217                if next > MAX_SPACE_DEPTH {
218                    return Err(ActionError::InvalidInput("space depth exceeded"));
219                }
220                next
221            }
222            None => 0,
223        };
224
225        // Stamp the injected creator into the stored config (authenticated
226        // creator), then spawn + attach.
227        let config = self.config.clone().into_config(creator);
228        let space_entity = ctx.spawn_entity_for::<SpaceConfig>()?;
229        ctx.set_component(space_entity, &config)?;
230        ctx.set_component(
231            space_entity,
232            &ParentChainDepth {
233                schema_version: 1,
234                depth: child_depth,
235            },
236        )?;
237        Ok(())
238    }
239}
240
241/// Maximum parent-chain depth (invariant E-space-4). Deeper trees reject with
242/// `DepthExceeded`.
243pub const MAX_SPACE_DEPTH: u8 = 64;
244
245#[cfg(test)]
246#[allow(clippy::unwrap_used, clippy::expect_used)]
247mod tests {
248    use super::*;
249    use crate::action::ArkheAction;
250    use crate::component::ArkheComponent;
251
252    fn ent(v: u64) -> EntityId {
253        EntityId::new(v).unwrap()
254    }
255
256    #[test]
257    fn space_config_serde_roundtrip_postcard() {
258        let cfg = SpaceConfig {
259            schema_version: 1,
260            shell_id: ShellId([0x01; 16]),
261            slug: BoundedString::<32>::new("general").unwrap(),
262            kind: SpaceKind::Tree,
263            visibility: Visibility::Public,
264            creator: ActorId::new(ent(42)),
265            parent_space: None,
266            created_tick: Tick(0),
267        };
268        let bytes = postcard::to_stdvec(&cfg).unwrap();
269        let back: SpaceConfig = postcard::from_bytes(&bytes).unwrap();
270        assert_eq!(cfg, back);
271    }
272
273    #[test]
274    fn space_membership_preserves_canonical_order() {
275        let mut set = BTreeSet::new();
276        set.insert(ActorId::new(ent(3)));
277        set.insert(ActorId::new(ent(1)));
278        set.insert(ActorId::new(ent(2)));
279        let m = SpaceMembership {
280            schema_version: 1,
281            members: set,
282        };
283        let serialized_once = postcard::to_stdvec(&m).unwrap();
284
285        let mut set2 = BTreeSet::new();
286        set2.insert(ActorId::new(ent(2)));
287        set2.insert(ActorId::new(ent(1)));
288        set2.insert(ActorId::new(ent(3)));
289        let m2 = SpaceMembership {
290            schema_version: 1,
291            members: set2,
292        };
293        assert_eq!(serialized_once, postcard::to_stdvec(&m2).unwrap());
294    }
295
296    #[test]
297    fn space_config_action_type_codes() {
298        assert_eq!(SpaceConfig::TYPE_CODE, 0x0003_0201);
299        assert_eq!(ParentChainDepth::TYPE_CODE, 0x0003_0202);
300        assert_eq!(SpaceMembership::TYPE_CODE, 0x0003_0203);
301        assert_eq!(CreateSpace::TYPE_CODE, 0x0001_0201);
302        assert_eq!(CreateSpace::BAND, 1);
303    }
304
305    #[test]
306    fn max_space_depth_is_sixty_four() {
307        assert_eq!(MAX_SPACE_DEPTH, 64);
308    }
309
310    fn draft() -> SpaceConfigDraft {
311        SpaceConfigDraft {
312            schema_version: 1,
313            shell_id: ShellId([0x01; 16]),
314            slug: BoundedString::<32>::new("general").unwrap(),
315            kind: SpaceKind::Flat,
316            visibility: Visibility::Public,
317            parent_space: None,
318            created_tick: Tick(0),
319        }
320    }
321
322    #[test]
323    fn create_space_records_injected_creator_not_a_wire_field() {
324        use crate::action::ActionCompute;
325        use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
326
327        let injected = ActorId::new(ent(0xC1));
328        let act = CreateSpace {
329            schema_version: 1,
330            config: draft(),
331        };
332        let mut c = ActionContext::new(
333            [0u8; 32],
334            InstanceId::new(1).unwrap(),
335            Tick(7),
336            Principal::System,
337            CapabilityMask::SYSTEM,
338        )
339        .with_actor(Some(injected));
340        act.compute(&mut c).expect("injected creator → compute ok");
341        let recorded = c.ops().iter().find_map(|op| match op {
342            arkhe_kernel::state::Op::SetComponent {
343                type_code, bytes, ..
344            } if *type_code == TypeCode(SpaceConfig::TYPE_CODE) => {
345                postcard::from_bytes::<SpaceConfig>(bytes).ok()
346            }
347            _ => None,
348        });
349        assert_eq!(
350            recorded.expect("config present").creator,
351            injected,
352            "recorded creator must equal the injected acting actor",
353        );
354    }
355
356    #[test]
357    fn create_space_rejects_wire_schema_mismatch() {
358        use crate::action::ActionCompute;
359        use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
360
361        let mut c = ActionContext::new(
362            [0u8; 32],
363            InstanceId::new(1).unwrap(),
364            Tick(7),
365            Principal::System,
366            CapabilityMask::SYSTEM,
367        )
368        .with_actor(Some(ActorId::new(ent(0xC1))));
369
370        // Action-level wire field — first check, fires before the auth gate.
371        let mut act = CreateSpace {
372            schema_version: 0xBEEF,
373            config: draft(),
374        };
375        let err = act
376            .compute(&mut c)
377            .expect_err("wire schema mismatch must reject");
378        assert!(
379            matches!(
380                err,
381                ActionError::SchemaMismatch {
382                    expected: 1,
383                    got: 0xBEEF,
384                }
385            ),
386            "got {err:?}",
387        );
388        assert!(c.ops().is_empty(), "no Ops on rejection");
389
390        // Nested config field — validated before the copy into the stored
391        // SpaceConfig.
392        act.schema_version = 1;
393        act.config.schema_version = 0xBEEF;
394        let err = act
395            .compute(&mut c)
396            .expect_err("config schema mismatch must reject");
397        assert!(
398            matches!(
399                err,
400                ActionError::SchemaMismatch {
401                    expected: 1,
402                    got: 0xBEEF,
403                }
404            ),
405            "got {err:?}",
406        );
407        assert!(c.ops().is_empty(), "no Ops on rejection");
408
409        // Matching versions proceed.
410        act.config.schema_version = 1;
411        act.compute(&mut c).expect("matching versions → Ok");
412    }
413
414    #[test]
415    fn create_space_without_injected_actor_rejects() {
416        use crate::action::ActionCompute;
417        use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
418
419        let act = CreateSpace {
420            schema_version: 1,
421            config: draft(),
422        };
423        let mut c = ActionContext::new(
424            [0u8; 32],
425            InstanceId::new(1).unwrap(),
426            Tick(7),
427            Principal::System,
428            CapabilityMask::SYSTEM,
429        );
430        let err = act
431            .compute(&mut c)
432            .expect_err("no injected actor must reject");
433        assert!(matches!(err, ActionError::AuthorizationFailed(_)));
434        assert!(c.ops().is_empty(), "no Ops on rejection");
435    }
436}