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]
142#[repr(u8)]
143#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
144pub enum TargetKind {
145 Entry(EntryId) = 0,
147 Actor(ActorId) = 1,
149 Space(SpaceId) = 2,
151 Activity(ActivityId) = 3,
153 Extension {
155 type_code: TypeCode,
157 id: EntityId,
159 } = 4,
160}
161
162#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
167pub struct TargetKey {
168 pub kind_code: u8,
170 pub type_code: TypeCode,
172 pub id: EntityId,
174 pub target_shell_id: ShellId,
176}
177
178impl TargetKind {
179 #[must_use]
190 pub fn key(&self, _ctx: &ActionContext<'_>) -> TargetKey {
191 self.key_with_shell(ShellId([0u8; 16]))
192 }
193
194 #[must_use]
198 pub fn key_with_shell(&self, target_shell_id: ShellId) -> TargetKey {
199 let (kind_code, type_code, id) = match self {
200 Self::Entry(e) => (1u8, TypeCode(0), e.get()),
201 Self::Actor(a) => (2u8, TypeCode(0), a.get()),
202 Self::Space(s) => (3u8, TypeCode(0), s.get()),
203 Self::Activity(a) => (4u8, TypeCode(0), a.get()),
204 Self::Extension { type_code, id } => (5u8, *type_code, *id),
205 };
206 TargetKey {
207 kind_code,
208 type_code,
209 id,
210 target_shell_id,
211 }
212 }
213}
214
215#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
218#[arkhe(type_code = 0x0003_0402, schema_version = 1)]
219pub struct EntityShellId {
220 pub schema_version: u16,
222 pub shell_id: ShellId,
224}
225
226#[non_exhaustive]
228#[repr(u8)]
229#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
230pub enum ActivityStatus {
231 Active = 0,
233 Retracted {
235 at: Tick,
237 } = 1,
238}
239
240#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
243#[arkhe(type_code = 0x0003_0401, schema_version = 1)]
244pub struct ActivityRecord {
245 pub schema_version: u16,
247 pub shell_id: ShellId,
249 pub actor: ActorId,
251 pub verb: VerbCode,
253 pub target: TargetKind,
255 pub at_tick: Tick,
257 pub status: ActivityStatus,
259 pub extra_bytes: Bytes,
262}
263
264#[derive(Clone)]
267pub struct Activity<'s> {
268 brand: ShellBrand<'s>,
269 inner: ActivityRecord,
270}
271
272impl<'s> Activity<'s> {
273 #[inline]
278 #[must_use]
279 pub fn new(brand: ShellBrand<'s>, inner: ActivityRecord) -> Self {
280 Self { brand, inner }
281 }
282
283 #[inline]
285 #[must_use]
286 pub fn inner(&self) -> &ActivityRecord {
287 &self.inner
288 }
289
290 #[inline]
292 #[must_use]
293 pub fn brand(&self) -> ShellBrand<'s> {
294 self.brand
295 }
296}
297
298#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
307pub struct ActivityDraft {
308 pub schema_version: u16,
310 pub shell_id: ShellId,
312 pub verb: VerbCode,
314 pub target: TargetKind,
316 pub at_tick: Tick,
318 pub status: ActivityStatus,
320 pub extra_bytes: Bytes,
323}
324
325impl ActivityDraft {
326 #[must_use]
330 fn into_record(self, actor: ActorId) -> ActivityRecord {
331 ActivityRecord {
332 schema_version: self.schema_version,
333 shell_id: self.shell_id,
334 actor,
335 verb: self.verb,
336 target: self.target,
337 at_tick: self.at_tick,
338 status: self.status,
339 extra_bytes: self.extra_bytes,
340 }
341 }
342}
343
344#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
352#[arkhe(type_code = 0x0001_0401, schema_version = 1, band = 1, idempotent)]
353pub struct SubmitActivity {
354 pub schema_version: u16,
356 pub draft: ActivityDraft,
358 pub idempotency_key: Option<[u8; 16]>,
360}
361
362impl SubmitActivity {
363 #[inline]
367 #[must_use]
368 pub fn from_branded(a: Activity<'_>) -> Self {
369 let r = a.inner;
370 Self {
371 schema_version: 1,
372 draft: ActivityDraft {
373 schema_version: r.schema_version,
374 shell_id: r.shell_id,
375 verb: r.verb,
376 target: r.target,
377 at_tick: r.at_tick,
378 status: r.status,
379 extra_bytes: r.extra_bytes,
380 },
381 idempotency_key: None,
382 }
383 }
384
385 #[inline]
387 #[must_use]
388 pub fn with_idempotency_key(mut self, key: [u8; 16]) -> Self {
389 self.idempotency_key = Some(key);
390 self
391 }
392}
393
394#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
397#[arkhe(type_code = 0x0001_0402, schema_version = 1, band = 1)]
398pub struct RetractActivity {
399 pub schema_version: u16,
401 pub activity: ActivityId,
403}
404
405impl ActionCompute for SubmitActivity {
406 #[arkhe_pure]
407 fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
408 let actor = ctx.acting_actor().ok_or(ActionError::AuthorizationFailed(
412 "activity requires an authenticated actor",
413 ))?;
414
415 ctx.ensure_actor_eligible(actor, ctx.tick())?;
419
420 if let Some(key) = self.idempotency_key {
421 if ctx.idempotency_lookup(&key).is_some() {
422 return Err(ActionError::IdempotencyConflict(key));
423 }
424 }
425
426 let predicted = ctx.preview_next_id_for::<ActivityRecord>()?;
431 if let TargetKind::Activity(target) = &self.draft.target {
432 if target.get() == predicted {
433 return Err(ActionError::InvalidInput("activity self-loop"));
434 }
435 }
436
437 let record = self.draft.clone().into_record(actor);
440 let activity_entity = ctx.spawn_entity_for::<ActivityRecord>()?;
441 ctx.set_component(activity_entity, &record)?;
442 Ok(())
443 }
444}
445
446impl ActionCompute for RetractActivity {
447 #[arkhe_pure]
448 fn compute<'i>(&self, _ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
449 Ok(())
453 }
454}
455
456pub const APPEAL_MAX_DEPTH_CAP: u8 = 8;
460
461#[cfg(test)]
462#[allow(clippy::unwrap_used, clippy::expect_used)]
463mod tests {
464 use super::*;
465 use crate::action::ArkheAction;
466 use crate::component::ArkheComponent;
467
468 fn ent(v: u64) -> EntityId {
469 EntityId::new(v).unwrap()
470 }
471
472 #[test]
473 fn verb_code_canonical_extraction() {
474 let vc = VerbCode::canonical(canonical_verbs::LIKE);
475 assert_eq!(vc.code(), TypeCode(0x0002_0001));
476 }
477
478 #[test]
479 fn verb_code_canonical_vs_shell_differ() {
480 let like = VerbCode::canonical(canonical_verbs::LIKE);
481 let custom = VerbCode::shell(ShellVerb::<0x0002_0400>::try_new().unwrap());
482 assert_ne!(like, custom);
483 }
484
485 #[test]
486 fn shell_verb_try_new_rejects_out_of_range_const() {
487 assert!(ShellVerb::<0x0001_0000>::try_new().is_none());
488 assert!(ShellVerb::<0x0003_0000>::try_new().is_none());
489 assert!(ShellVerb::<0x0002_0400>::try_new().is_some());
490 assert!(ShellVerb::<0x0002_FFFF>::try_new().is_some());
491 }
492
493 #[test]
494 fn activity_record_serde_roundtrip_postcard() {
495 let rec = ActivityRecord {
496 schema_version: 1,
497 shell_id: ShellId([0u8; 16]),
498 actor: ActorId::new(ent(1)),
499 verb: VerbCode::canonical(canonical_verbs::LIKE),
500 target: TargetKind::Entry(EntryId::new(ent(2))),
501 at_tick: Tick(5),
502 status: ActivityStatus::Active,
503 extra_bytes: Bytes::new(),
504 };
505 let bytes = postcard::to_stdvec(&rec).unwrap();
506 let back: ActivityRecord = postcard::from_bytes(&bytes).unwrap();
507 assert_eq!(rec, back);
508 }
509
510 #[test]
511 fn submit_activity_drops_actor_on_branded_unwrap() {
512 ShellBrand::run(|brand| {
516 let rec = ActivityRecord {
517 schema_version: 1,
518 shell_id: ShellId([0u8; 16]),
519 actor: ActorId::new(ent(1)),
520 verb: VerbCode::canonical(canonical_verbs::FOLLOW),
521 target: TargetKind::Actor(ActorId::new(ent(2))),
522 at_tick: Tick(0),
523 status: ActivityStatus::Active,
524 extra_bytes: Bytes::new(),
525 };
526 let activity = Activity::new(brand, rec.clone());
527 let submit = SubmitActivity::from_branded(activity);
528 assert_eq!(submit.draft.shell_id, rec.shell_id);
530 assert_eq!(submit.draft.verb, rec.verb);
531 assert_eq!(submit.draft.target, rec.target);
532 assert!(submit.idempotency_key.is_none());
533 });
534 }
535
536 #[test]
537 fn submit_activity_with_idempotency_key_attaches() {
538 let draft = ActivityDraft {
539 schema_version: 1,
540 shell_id: ShellId([0u8; 16]),
541 verb: VerbCode::canonical(canonical_verbs::LIKE),
542 target: TargetKind::Entry(EntryId::new(ent(2))),
543 at_tick: Tick(0),
544 status: ActivityStatus::Active,
545 extra_bytes: Bytes::new(),
546 };
547 let submit = SubmitActivity {
548 schema_version: 1,
549 draft,
550 idempotency_key: None,
551 }
552 .with_idempotency_key([0xCD; 16]);
553 assert_eq!(submit.idempotency_key, Some([0xCD; 16]));
554 }
555
556 #[test]
557 fn submit_activity_records_injected_actor_not_a_wire_field() {
558 use crate::context::ActionContext;
559 use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
560
561 let injected = ActorId::new(ent(0xAC));
562 let submit = SubmitActivity {
563 schema_version: 1,
564 draft: ActivityDraft {
565 schema_version: 1,
566 shell_id: ShellId([0u8; 16]),
567 verb: VerbCode::canonical(canonical_verbs::LIKE),
568 target: TargetKind::Entry(EntryId::new(ent(2))),
569 at_tick: Tick(0),
570 status: ActivityStatus::Active,
571 extra_bytes: Bytes::new(),
572 },
573 idempotency_key: None,
574 };
575 let mut c = ActionContext::new(
576 [0u8; 32],
577 InstanceId::new(1).unwrap(),
578 Tick(7),
579 Principal::System,
580 CapabilityMask::SYSTEM,
581 )
582 .with_actor(Some(injected));
583 submit.compute(&mut c).expect("injected actor → compute ok");
584 let recorded = c.ops().iter().find_map(|op| match op {
585 arkhe_kernel::state::Op::SetComponent {
586 type_code, bytes, ..
587 } if *type_code == TypeCode(ActivityRecord::TYPE_CODE) => {
588 postcard::from_bytes::<ActivityRecord>(bytes).ok()
589 }
590 _ => None,
591 });
592 assert_eq!(
593 recorded.expect("record present").actor,
594 injected,
595 "recorded author must equal the injected acting actor",
596 );
597 }
598
599 #[test]
600 fn submit_activity_without_injected_actor_rejects() {
601 use crate::context::ActionContext;
602 use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
603
604 let submit = SubmitActivity {
605 schema_version: 1,
606 draft: ActivityDraft {
607 schema_version: 1,
608 shell_id: ShellId([0u8; 16]),
609 verb: VerbCode::canonical(canonical_verbs::LIKE),
610 target: TargetKind::Entry(EntryId::new(ent(2))),
611 at_tick: Tick(0),
612 status: ActivityStatus::Active,
613 extra_bytes: Bytes::new(),
614 },
615 idempotency_key: None,
616 };
617 let mut c = ActionContext::new(
618 [0u8; 32],
619 InstanceId::new(1).unwrap(),
620 Tick(7),
621 Principal::System,
622 CapabilityMask::SYSTEM,
623 );
624 let err = submit
625 .compute(&mut c)
626 .expect_err("no injected actor must reject");
627 assert!(matches!(err, ActionError::AuthorizationFailed(_)));
628 assert!(c.ops().is_empty(), "no Ops on rejection");
629 }
630
631 #[test]
632 fn activity_types_expose_trait_consts() {
633 assert_eq!(ActivityRecord::TYPE_CODE, 0x0003_0401);
634 assert_eq!(EntityShellId::TYPE_CODE, 0x0003_0402);
635 assert_eq!(SubmitActivity::TYPE_CODE, 0x0001_0401);
636 assert_eq!(SubmitActivity::BAND, 1);
637 assert_eq!(RetractActivity::TYPE_CODE, 0x0001_0402);
638 const { assert!(SubmitActivity::IDEMPOTENT) };
639 const { assert!(!RetractActivity::IDEMPOTENT) };
640 }
641
642 #[test]
643 fn target_kind_key_with_shell_stamps_discriminant_and_shell() {
644 let entry = TargetKind::Entry(EntryId::new(ent(7)));
645 let shell = ShellId([0x33u8; 16]);
646 let k = entry.key_with_shell(shell);
647 assert_eq!(k.kind_code, 1);
648 assert_eq!(k.id.get(), 7);
649 assert_eq!(k.target_shell_id, shell);
650
651 let ext = TargetKind::Extension {
652 type_code: TypeCode(0x0100_0001),
653 id: ent(9),
654 };
655 let k2 = ext.key_with_shell(shell);
656 assert_eq!(k2.kind_code, 5);
657 assert_eq!(k2.type_code, TypeCode(0x0100_0001));
658 }
659
660 #[test]
661 fn appeal_cap_is_eight() {
662 assert_eq!(APPEAL_MAX_DEPTH_CAP, 8);
663 }
664}