1use 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;
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 ensure_schema_version(Self::SCHEMA_VERSION, self.schema_version)?;
193 ensure_schema_version(SpaceConfig::SCHEMA_VERSION, self.config.schema_version)?;
194
195 let creator = ctx.acting_actor().ok_or(ActionError::AuthorizationFailed(
199 "space requires an authenticated actor",
200 ))?;
201
202 ctx.ensure_actor_eligible(creator, ctx.tick())?;
206
207 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 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
241pub 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 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 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 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}