1use arkhe_kernel::abi::{EntityId, Tick, TypeCode};
14use bytes::Bytes;
15use serde::{Deserialize, Serialize};
16
17use crate::action::ActionCompute;
18use crate::actor::ActorId;
19use crate::brand::{ShellBrand, ShellId};
20use crate::context::{ActionContext, ActionError};
21use crate::entry::EntryId;
22use crate::space::SpaceId;
23use crate::ArkheAction;
24use crate::ArkheComponent;
25use crate::arkhe_pure;
27
28#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
30#[serde(transparent)]
31pub struct ActivityId(EntityId);
32
33impl ActivityId {
34 #[inline]
38 #[must_use]
39 pub fn new(id: EntityId) -> Self {
40 Self(id)
41 }
42
43 #[inline]
45 #[must_use]
46 pub fn get(self) -> EntityId {
47 self.0
48 }
49}
50
51pub struct CanonicalVerb<const C: u32> {
57 _private: (),
58}
59
60pub struct ShellVerb<const C: u32> {
68 _private: (),
69}
70
71impl<const C: u32> ShellVerb<C> {
72 #[inline]
75 #[must_use]
76 pub const fn try_new() -> Option<Self> {
77 if C >= 0x0002_0400 && C <= 0x0002_FFFF {
78 Some(Self { _private: () })
79 } else {
80 None
81 }
82 }
83}
84
85#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
87#[serde(transparent)]
88pub struct VerbCode(TypeCode);
89
90impl VerbCode {
91 #[inline]
93 #[must_use]
94 pub const fn canonical<const C: u32>(_witness: CanonicalVerb<C>) -> Self {
95 Self(TypeCode(C))
96 }
97
98 #[inline]
100 #[must_use]
101 pub const fn shell<const C: u32>(_witness: ShellVerb<C>) -> Self {
102 Self(TypeCode(C))
103 }
104
105 #[inline]
107 #[must_use]
108 pub const fn code(self) -> TypeCode {
109 self.0
110 }
111}
112
113pub mod canonical_verbs {
115 use super::CanonicalVerb;
116
117 pub const LIKE: CanonicalVerb<0x0002_0001> = CanonicalVerb { _private: () };
119 pub const FOLLOW: CanonicalVerb<0x0002_0002> = CanonicalVerb { _private: () };
121 pub const BOOKMARK: CanonicalVerb<0x0002_0003> = CanonicalVerb { _private: () };
123 pub const REPORT: CanonicalVerb<0x0002_0004> = CanonicalVerb { _private: () };
125 pub const MUTE: CanonicalVerb<0x0002_0005> = CanonicalVerb { _private: () };
127 pub const BLOCK: CanonicalVerb<0x0002_0006> = CanonicalVerb { _private: () };
129 pub const PIN: CanonicalVerb<0x0002_0007> = CanonicalVerb { _private: () };
131 pub const FLAG: CanonicalVerb<0x0002_0008> = CanonicalVerb { _private: () };
133}
134
135#[non_exhaustive]
138#[repr(u8)]
139#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
140pub enum TargetKind {
141 Entry(EntryId) = 0,
143 Actor(ActorId) = 1,
145 Space(SpaceId) = 2,
147 Activity(ActivityId) = 3,
149 Extension {
151 type_code: TypeCode,
153 id: EntityId,
155 } = 4,
156}
157
158#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
163pub struct TargetKey {
164 pub kind_code: u8,
166 pub type_code: TypeCode,
168 pub id: EntityId,
170 pub target_shell_id: ShellId,
172}
173
174impl TargetKind {
175 #[must_use]
186 pub fn key(&self, _ctx: &ActionContext<'_>) -> TargetKey {
187 self.key_with_shell(ShellId([0u8; 16]))
188 }
189
190 #[must_use]
194 pub fn key_with_shell(&self, target_shell_id: ShellId) -> TargetKey {
195 let (kind_code, type_code, id) = match self {
196 Self::Entry(e) => (1u8, TypeCode(0), e.get()),
197 Self::Actor(a) => (2u8, TypeCode(0), a.get()),
198 Self::Space(s) => (3u8, TypeCode(0), s.get()),
199 Self::Activity(a) => (4u8, TypeCode(0), a.get()),
200 Self::Extension { type_code, id } => (5u8, *type_code, *id),
201 };
202 TargetKey {
203 kind_code,
204 type_code,
205 id,
206 target_shell_id,
207 }
208 }
209}
210
211#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
214#[arkhe(type_code = 0x0003_0402, schema_version = 1)]
215pub struct EntityShellId {
216 pub schema_version: u16,
218 pub shell_id: ShellId,
220}
221
222#[non_exhaustive]
224#[repr(u8)]
225#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
226pub enum ActivityStatus {
227 Active = 0,
229 Retracted {
231 at: Tick,
233 } = 1,
234}
235
236#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
239#[arkhe(type_code = 0x0003_0401, schema_version = 1)]
240pub struct ActivityRecord {
241 pub schema_version: u16,
243 pub shell_id: ShellId,
245 pub actor: ActorId,
247 pub verb: VerbCode,
249 pub target: TargetKind,
251 pub at_tick: Tick,
253 pub status: ActivityStatus,
255 pub extra_bytes: Bytes,
258}
259
260#[derive(Clone)]
263pub struct Activity<'s> {
264 brand: ShellBrand<'s>,
265 inner: ActivityRecord,
266}
267
268impl<'s> Activity<'s> {
269 #[inline]
274 #[must_use]
275 pub fn new(brand: ShellBrand<'s>, inner: ActivityRecord) -> Self {
276 Self { brand, inner }
277 }
278
279 #[inline]
281 #[must_use]
282 pub fn inner(&self) -> &ActivityRecord {
283 &self.inner
284 }
285
286 #[inline]
288 #[must_use]
289 pub fn brand(&self) -> ShellBrand<'s> {
290 self.brand
291 }
292}
293
294#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
297#[arkhe(type_code = 0x0001_0401, schema_version = 1, band = 1, idempotent)]
298pub struct SubmitActivity {
299 pub schema_version: u16,
301 pub record: ActivityRecord,
303 pub idempotency_key: Option<[u8; 16]>,
305}
306
307impl SubmitActivity {
308 #[inline]
310 #[must_use]
311 pub fn from_branded(a: Activity<'_>) -> Self {
312 Self {
313 schema_version: 1,
314 record: a.inner,
315 idempotency_key: None,
316 }
317 }
318
319 #[inline]
321 #[must_use]
322 pub fn with_idempotency_key(mut self, key: [u8; 16]) -> Self {
323 self.idempotency_key = Some(key);
324 self
325 }
326}
327
328#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
331#[arkhe(type_code = 0x0001_0402, schema_version = 1, band = 1)]
332pub struct RetractActivity {
333 pub schema_version: u16,
335 pub activity: ActivityId,
337}
338
339impl ActionCompute for SubmitActivity {
340 #[arkhe_pure]
341 fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
342 ctx.ensure_actor_eligible(self.record.actor, ctx.tick())?;
346
347 if let Some(key) = self.idempotency_key {
348 if ctx.idempotency_lookup(&key).is_some() {
349 return Err(ActionError::IdempotencyConflict(key));
350 }
351 }
352
353 let predicted = ctx.preview_next_id_for::<ActivityRecord>()?;
358 if let TargetKind::Activity(target) = &self.record.target {
359 if target.get() == predicted {
360 return Err(ActionError::InvalidInput("activity self-loop"));
361 }
362 }
363
364 let activity_entity = ctx.spawn_entity_for::<ActivityRecord>()?;
365 ctx.set_component(activity_entity, &self.record)?;
366 Ok(())
367 }
368}
369
370impl ActionCompute for RetractActivity {
371 #[arkhe_pure]
372 fn compute<'i>(&self, _ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
373 Ok(())
377 }
378}
379
380pub const APPEAL_MAX_DEPTH_CAP: u8 = 8;
384
385#[cfg(test)]
386#[allow(clippy::unwrap_used, clippy::expect_used)]
387mod tests {
388 use super::*;
389 use crate::action::ArkheAction;
390 use crate::component::ArkheComponent;
391
392 fn ent(v: u64) -> EntityId {
393 EntityId::new(v).unwrap()
394 }
395
396 #[test]
397 fn verb_code_canonical_extraction() {
398 let vc = VerbCode::canonical(canonical_verbs::LIKE);
399 assert_eq!(vc.code(), TypeCode(0x0002_0001));
400 }
401
402 #[test]
403 fn verb_code_canonical_vs_shell_differ() {
404 let like = VerbCode::canonical(canonical_verbs::LIKE);
405 let custom = VerbCode::shell(ShellVerb::<0x0002_0400>::try_new().unwrap());
406 assert_ne!(like, custom);
407 }
408
409 #[test]
410 fn shell_verb_try_new_rejects_out_of_range_const() {
411 assert!(ShellVerb::<0x0001_0000>::try_new().is_none());
412 assert!(ShellVerb::<0x0003_0000>::try_new().is_none());
413 assert!(ShellVerb::<0x0002_0400>::try_new().is_some());
414 assert!(ShellVerb::<0x0002_FFFF>::try_new().is_some());
415 }
416
417 #[test]
418 fn activity_record_serde_roundtrip_postcard() {
419 let rec = ActivityRecord {
420 schema_version: 1,
421 shell_id: ShellId([0u8; 16]),
422 actor: ActorId::new(ent(1)),
423 verb: VerbCode::canonical(canonical_verbs::LIKE),
424 target: TargetKind::Entry(EntryId::new(ent(2))),
425 at_tick: Tick(5),
426 status: ActivityStatus::Active,
427 extra_bytes: Bytes::new(),
428 };
429 let bytes = postcard::to_stdvec(&rec).unwrap();
430 let back: ActivityRecord = postcard::from_bytes(&bytes).unwrap();
431 assert_eq!(rec, back);
432 }
433
434 #[test]
435 fn submit_activity_preserves_record_on_branded_unwrap() {
436 ShellBrand::run(|brand| {
437 let rec = ActivityRecord {
438 schema_version: 1,
439 shell_id: ShellId([0u8; 16]),
440 actor: ActorId::new(ent(1)),
441 verb: VerbCode::canonical(canonical_verbs::FOLLOW),
442 target: TargetKind::Actor(ActorId::new(ent(2))),
443 at_tick: Tick(0),
444 status: ActivityStatus::Active,
445 extra_bytes: Bytes::new(),
446 };
447 let activity = Activity::new(brand, rec.clone());
448 let submit = SubmitActivity::from_branded(activity);
449 assert_eq!(submit.record, rec);
450 assert!(submit.idempotency_key.is_none());
451 });
452 }
453
454 #[test]
455 fn submit_activity_with_idempotency_key_attaches() {
456 let rec = ActivityRecord {
457 schema_version: 1,
458 shell_id: ShellId([0u8; 16]),
459 actor: ActorId::new(ent(1)),
460 verb: VerbCode::canonical(canonical_verbs::LIKE),
461 target: TargetKind::Entry(EntryId::new(ent(2))),
462 at_tick: Tick(0),
463 status: ActivityStatus::Active,
464 extra_bytes: Bytes::new(),
465 };
466 let submit = SubmitActivity {
467 schema_version: 1,
468 record: rec,
469 idempotency_key: None,
470 }
471 .with_idempotency_key([0xCD; 16]);
472 assert_eq!(submit.idempotency_key, Some([0xCD; 16]));
473 }
474
475 #[test]
476 fn activity_types_expose_trait_consts() {
477 assert_eq!(ActivityRecord::TYPE_CODE, 0x0003_0401);
478 assert_eq!(EntityShellId::TYPE_CODE, 0x0003_0402);
479 assert_eq!(SubmitActivity::TYPE_CODE, 0x0001_0401);
480 assert_eq!(SubmitActivity::BAND, 1);
481 assert_eq!(RetractActivity::TYPE_CODE, 0x0001_0402);
482 const { assert!(SubmitActivity::IDEMPOTENT) };
483 const { assert!(!RetractActivity::IDEMPOTENT) };
484 }
485
486 #[test]
487 fn target_kind_key_with_shell_stamps_discriminant_and_shell() {
488 let entry = TargetKind::Entry(EntryId::new(ent(7)));
489 let shell = ShellId([0x33u8; 16]);
490 let k = entry.key_with_shell(shell);
491 assert_eq!(k.kind_code, 1);
492 assert_eq!(k.id.get(), 7);
493 assert_eq!(k.target_shell_id, shell);
494
495 let ext = TargetKind::Extension {
496 type_code: TypeCode(0x0100_0001),
497 id: ent(9),
498 };
499 let k2 = ext.key_with_shell(shell);
500 assert_eq!(k2.kind_code, 5);
501 assert_eq!(k2.type_code, TypeCode(0x0100_0001));
502 }
503
504 #[test]
505 fn appeal_cap_is_eight() {
506 assert_eq!(APPEAL_MAX_DEPTH_CAP, 8);
507 }
508}