1use 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;
15use crate::arkhe_pure;
17
18#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct SpaceId(EntityId);
22
23impl SpaceId {
24 #[inline]
28 #[must_use]
29 pub fn new(id: EntityId) -> Self {
30 Self(id)
31 }
32
33 #[inline]
35 #[must_use]
36 pub fn get(self) -> EntityId {
37 self.0
38 }
39}
40
41#[non_exhaustive]
48#[repr(u8)]
49#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
50pub enum SpaceKind {
51 Flat = 0,
53 Tree = 1,
55 Graph = 2,
57 Hashtag = 3,
59 ActorFeed = 4,
61 Extension {
63 type_code: TypeCode,
65 } = 255,
66}
67
68#[non_exhaustive]
70#[repr(u8)]
71#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
72pub enum Visibility {
73 Public = 0,
75 RestrictedByRole = 1,
77 SubscribersOnly = 2,
79 PrivateInvite = 3,
81 Encrypted = 4,
83}
84
85#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
87#[arkhe(type_code = 0x0003_0201, schema_version = 1)]
88pub struct SpaceConfig {
89 pub schema_version: u16,
91 pub shell_id: ShellId,
93 pub slug: BoundedString<32>,
95 pub kind: SpaceKind,
97 pub visibility: Visibility,
99 pub creator: ActorId,
101 pub parent_space: Option<SpaceId>,
103 pub created_tick: Tick,
105}
106
107#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
110#[arkhe(type_code = 0x0003_0202, schema_version = 1)]
111pub struct ParentChainDepth {
112 pub schema_version: u16,
114 pub depth: u8,
116}
117
118#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
120#[arkhe(type_code = 0x0003_0203, schema_version = 1)]
121pub struct SpaceMembership {
122 pub schema_version: u16,
124 pub members: BTreeSet<ActorId>,
127}
128
129#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
135pub struct SpaceConfigDraft {
136 pub schema_version: u16,
138 pub shell_id: ShellId,
140 pub slug: BoundedString<32>,
142 pub kind: SpaceKind,
144 pub visibility: Visibility,
146 pub parent_space: Option<SpaceId>,
148 pub created_tick: Tick,
150}
151
152impl SpaceConfigDraft {
153 #[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#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
178#[arkhe(type_code = 0x0001_0201, schema_version = 1, band = 1)]
179pub struct CreateSpace {
180 pub schema_version: u16,
182 pub config: SpaceConfigDraft,
184}
185
186impl ActionCompute for CreateSpace {
187 #[arkhe_pure]
188 fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
189 let creator = ctx.acting_actor().ok_or(ActionError::AuthorizationFailed(
193 "space requires an authenticated actor",
194 ))?;
195
196 ctx.ensure_actor_eligible(creator, ctx.tick())?;
200
201 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 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
235pub 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}