1use arkhe_kernel::abi::{EntityId, Tick, TypeCode};
14use bytes::Bytes;
15use serde::{Deserialize, Serialize};
16
17use crate::action::{ActionCompute, ArkheAction as _};
18use crate::actor::ActorId;
19use crate::brand::{ShellBrand, ShellId};
20use crate::component::ArkheComponent as _;
21use crate::context::{ensure_schema_version, ActionContext, ActionError};
22use crate::entry::EntryId;
23use crate::space::SpaceId;
24use crate::ArkheAction;
25use crate::ArkheComponent;
26use crate::arkhe_pure;
28
29#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
31#[serde(transparent)]
32pub struct ActivityId(EntityId);
33
34impl ActivityId {
35 #[inline]
39 #[must_use]
40 pub fn new(id: EntityId) -> Self {
41 Self(id)
42 }
43
44 #[inline]
46 #[must_use]
47 pub fn get(self) -> EntityId {
48 self.0
49 }
50}
51
52pub struct CanonicalVerb<const C: u32> {
58 _private: (),
59}
60
61pub struct ShellVerb<const C: u32> {
69 _private: (),
70}
71
72impl<const C: u32> ShellVerb<C> {
73 #[inline]
76 #[must_use]
77 pub const fn try_new() -> Option<Self> {
78 if C >= 0x0002_0400 && C <= 0x0002_FFFF {
79 Some(Self { _private: () })
80 } else {
81 None
82 }
83 }
84}
85
86#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
88#[serde(transparent)]
89pub struct VerbCode(TypeCode);
90
91impl VerbCode {
92 #[inline]
94 #[must_use]
95 pub const fn canonical<const C: u32>(_witness: CanonicalVerb<C>) -> Self {
96 Self(TypeCode(C))
97 }
98
99 #[inline]
101 #[must_use]
102 pub const fn shell<const C: u32>(_witness: ShellVerb<C>) -> Self {
103 Self(TypeCode(C))
104 }
105
106 #[inline]
108 #[must_use]
109 pub const fn code(self) -> TypeCode {
110 self.0
111 }
112}
113
114pub mod canonical_verbs {
116 use super::CanonicalVerb;
117
118 pub const LIKE: CanonicalVerb<0x0002_0001> = CanonicalVerb { _private: () };
120 pub const FOLLOW: CanonicalVerb<0x0002_0002> = CanonicalVerb { _private: () };
122 pub const BOOKMARK: CanonicalVerb<0x0002_0003> = CanonicalVerb { _private: () };
124 pub const REPORT: CanonicalVerb<0x0002_0004> = CanonicalVerb { _private: () };
126 pub const MUTE: CanonicalVerb<0x0002_0005> = CanonicalVerb { _private: () };
128 pub const BLOCK: CanonicalVerb<0x0002_0006> = CanonicalVerb { _private: () };
130 pub const PIN: CanonicalVerb<0x0002_0007> = CanonicalVerb { _private: () };
132 pub const FLAG: CanonicalVerb<0x0002_0008> = CanonicalVerb { _private: () };
134}
135
136#[non_exhaustive]
143#[repr(u8)]
144#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
145pub enum TargetKind {
146 Entry(EntryId) = 0,
148 Actor(ActorId) = 1,
150 Space(SpaceId) = 2,
152 Activity(ActivityId) = 3,
154 Extension {
156 type_code: TypeCode,
158 id: EntityId,
160 } = 4,
161}
162
163#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
168pub struct TargetKey {
169 pub kind_code: u8,
171 pub type_code: TypeCode,
173 pub id: EntityId,
175 pub target_shell_id: ShellId,
177}
178
179impl TargetKind {
180 #[must_use]
191 pub fn key(&self, _ctx: &ActionContext<'_>) -> TargetKey {
192 self.key_with_shell(ShellId([0u8; 16]))
193 }
194
195 #[must_use]
199 pub fn key_with_shell(&self, target_shell_id: ShellId) -> TargetKey {
200 let (kind_code, type_code, id) = match self {
201 Self::Entry(e) => (1u8, TypeCode(0), e.get()),
202 Self::Actor(a) => (2u8, TypeCode(0), a.get()),
203 Self::Space(s) => (3u8, TypeCode(0), s.get()),
204 Self::Activity(a) => (4u8, TypeCode(0), a.get()),
205 Self::Extension { type_code, id } => (5u8, *type_code, *id),
206 };
207 TargetKey {
208 kind_code,
209 type_code,
210 id,
211 target_shell_id,
212 }
213 }
214}
215
216#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
219#[arkhe(type_code = 0x0003_0402, schema_version = 1)]
220pub struct EntityShellId {
221 pub schema_version: u16,
223 pub shell_id: ShellId,
225}
226
227#[non_exhaustive]
229#[repr(u8)]
230#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
231pub enum ActivityStatus {
232 Active = 0,
234 Retracted {
236 at: Tick,
238 } = 1,
239}
240
241#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
246#[arkhe(type_code = 0x0003_0401, schema_version = 1)]
247pub struct ActivityRecord {
248 pub schema_version: u16,
250 pub shell_id: ShellId,
252 pub actor: ActorId,
254 pub verb: VerbCode,
256 pub target: TargetKind,
258 pub at_tick: Tick,
260 pub status: ActivityStatus,
262 pub extra_bytes: Bytes,
265}
266
267#[derive(Clone)]
270pub struct Activity<'s> {
271 brand: ShellBrand<'s>,
272 inner: ActivityRecord,
273}
274
275impl<'s> Activity<'s> {
276 #[inline]
281 #[must_use]
282 pub fn new(brand: ShellBrand<'s>, inner: ActivityRecord) -> Self {
283 Self { brand, inner }
284 }
285
286 #[inline]
288 #[must_use]
289 pub fn inner(&self) -> &ActivityRecord {
290 &self.inner
291 }
292
293 #[inline]
295 #[must_use]
296 pub fn brand(&self) -> ShellBrand<'s> {
297 self.brand
298 }
299}
300
301#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
310pub struct ActivityDraft {
311 pub schema_version: u16,
313 pub shell_id: ShellId,
315 pub verb: VerbCode,
317 pub target: TargetKind,
319 pub at_tick: Tick,
321 pub status: ActivityStatus,
323 pub extra_bytes: Bytes,
326}
327
328impl ActivityDraft {
329 #[must_use]
333 fn into_record(self, actor: ActorId) -> ActivityRecord {
334 ActivityRecord {
335 schema_version: self.schema_version,
336 shell_id: self.shell_id,
337 actor,
338 verb: self.verb,
339 target: self.target,
340 at_tick: self.at_tick,
341 status: self.status,
342 extra_bytes: self.extra_bytes,
343 }
344 }
345}
346
347#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
355#[arkhe(type_code = 0x0001_0401, schema_version = 1, band = 1, idempotent)]
356pub struct SubmitActivity {
357 pub schema_version: u16,
359 pub draft: ActivityDraft,
361 pub idempotency_key: Option<[u8; 16]>,
363}
364
365impl SubmitActivity {
366 #[inline]
370 #[must_use]
371 pub fn from_branded(a: Activity<'_>) -> Self {
372 let r = a.inner;
373 Self {
374 schema_version: 1,
375 draft: ActivityDraft {
376 schema_version: r.schema_version,
377 shell_id: r.shell_id,
378 verb: r.verb,
379 target: r.target,
380 at_tick: r.at_tick,
381 status: r.status,
382 extra_bytes: r.extra_bytes,
383 },
384 idempotency_key: None,
385 }
386 }
387
388 #[inline]
390 #[must_use]
391 pub fn with_idempotency_key(mut self, key: [u8; 16]) -> Self {
392 self.idempotency_key = Some(key);
393 self
394 }
395}
396
397#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
400#[arkhe(type_code = 0x0001_0402, schema_version = 1, band = 1)]
401pub struct RetractActivity {
402 pub schema_version: u16,
404 pub activity: ActivityId,
406}
407
408impl ActionCompute for SubmitActivity {
409 #[arkhe_pure]
410 fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
411 ensure_schema_version(Self::SCHEMA_VERSION, self.schema_version)?;
415 ensure_schema_version(ActivityRecord::SCHEMA_VERSION, self.draft.schema_version)?;
416
417 let actor = ctx.acting_actor().ok_or(ActionError::AuthorizationFailed(
421 "activity requires an authenticated actor",
422 ))?;
423
424 ctx.ensure_actor_eligible(actor, ctx.tick())?;
428
429 if let Some(key) = self.idempotency_key {
430 if ctx.idempotency_lookup(&key).is_some() {
431 return Err(ActionError::IdempotencyConflict(key));
432 }
433 }
434
435 let predicted = ctx.preview_next_id_for::<ActivityRecord>()?;
440 if let TargetKind::Activity(target) = &self.draft.target {
441 if target.get() == predicted {
442 return Err(ActionError::InvalidInput("activity self-loop"));
443 }
444 }
445
446 let record = self.draft.clone().into_record(actor);
449 let activity_entity = ctx.spawn_entity_for::<ActivityRecord>()?;
450 ctx.set_component(activity_entity, &record)?;
451 Ok(())
452 }
453}
454
455impl ActionCompute for RetractActivity {
456 #[arkhe_pure]
457 fn compute<'i>(&self, _ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
458 ensure_schema_version(Self::SCHEMA_VERSION, self.schema_version)?;
459 Ok(())
463 }
464}
465
466pub const APPEAL_MAX_DEPTH_CAP: u8 = 8;
470
471#[cfg(test)]
472#[allow(clippy::unwrap_used, clippy::expect_used)]
473mod tests {
474 use super::*;
475 use crate::action::ArkheAction;
476 use crate::component::ArkheComponent;
477
478 fn ent(v: u64) -> EntityId {
479 EntityId::new(v).unwrap()
480 }
481
482 #[test]
483 fn verb_code_canonical_extraction() {
484 let vc = VerbCode::canonical(canonical_verbs::LIKE);
485 assert_eq!(vc.code(), TypeCode(0x0002_0001));
486 }
487
488 #[test]
489 fn verb_code_canonical_vs_shell_differ() {
490 let like = VerbCode::canonical(canonical_verbs::LIKE);
491 let custom = VerbCode::shell(ShellVerb::<0x0002_0400>::try_new().unwrap());
492 assert_ne!(like, custom);
493 }
494
495 #[test]
496 fn shell_verb_try_new_rejects_out_of_range_const() {
497 assert!(ShellVerb::<0x0001_0000>::try_new().is_none());
498 assert!(ShellVerb::<0x0003_0000>::try_new().is_none());
499 assert!(ShellVerb::<0x0002_0400>::try_new().is_some());
500 assert!(ShellVerb::<0x0002_FFFF>::try_new().is_some());
501 }
502
503 #[test]
504 fn activity_record_serde_roundtrip_postcard() {
505 let rec = ActivityRecord {
506 schema_version: 1,
507 shell_id: ShellId([0u8; 16]),
508 actor: ActorId::new(ent(1)),
509 verb: VerbCode::canonical(canonical_verbs::LIKE),
510 target: TargetKind::Entry(EntryId::new(ent(2))),
511 at_tick: Tick(5),
512 status: ActivityStatus::Active,
513 extra_bytes: Bytes::new(),
514 };
515 let bytes = postcard::to_stdvec(&rec).unwrap();
516 let back: ActivityRecord = postcard::from_bytes(&bytes).unwrap();
517 assert_eq!(rec, back);
518 }
519
520 #[test]
521 fn submit_activity_drops_actor_on_branded_unwrap() {
522 ShellBrand::run(|brand| {
526 let rec = ActivityRecord {
527 schema_version: 1,
528 shell_id: ShellId([0u8; 16]),
529 actor: ActorId::new(ent(1)),
530 verb: VerbCode::canonical(canonical_verbs::FOLLOW),
531 target: TargetKind::Actor(ActorId::new(ent(2))),
532 at_tick: Tick(0),
533 status: ActivityStatus::Active,
534 extra_bytes: Bytes::new(),
535 };
536 let activity = Activity::new(brand, rec.clone());
537 let submit = SubmitActivity::from_branded(activity);
538 assert_eq!(submit.draft.shell_id, rec.shell_id);
540 assert_eq!(submit.draft.verb, rec.verb);
541 assert_eq!(submit.draft.target, rec.target);
542 assert!(submit.idempotency_key.is_none());
543 });
544 }
545
546 #[test]
547 fn submit_activity_with_idempotency_key_attaches() {
548 let draft = ActivityDraft {
549 schema_version: 1,
550 shell_id: ShellId([0u8; 16]),
551 verb: VerbCode::canonical(canonical_verbs::LIKE),
552 target: TargetKind::Entry(EntryId::new(ent(2))),
553 at_tick: Tick(0),
554 status: ActivityStatus::Active,
555 extra_bytes: Bytes::new(),
556 };
557 let submit = SubmitActivity {
558 schema_version: 1,
559 draft,
560 idempotency_key: None,
561 }
562 .with_idempotency_key([0xCD; 16]);
563 assert_eq!(submit.idempotency_key, Some([0xCD; 16]));
564 }
565
566 #[test]
567 fn submit_activity_records_injected_actor_not_a_wire_field() {
568 use crate::context::ActionContext;
569 use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
570
571 let injected = ActorId::new(ent(0xAC));
572 let submit = SubmitActivity {
573 schema_version: 1,
574 draft: ActivityDraft {
575 schema_version: 1,
576 shell_id: ShellId([0u8; 16]),
577 verb: VerbCode::canonical(canonical_verbs::LIKE),
578 target: TargetKind::Entry(EntryId::new(ent(2))),
579 at_tick: Tick(0),
580 status: ActivityStatus::Active,
581 extra_bytes: Bytes::new(),
582 },
583 idempotency_key: None,
584 };
585 let mut c = ActionContext::new(
586 [0u8; 32],
587 InstanceId::new(1).unwrap(),
588 Tick(7),
589 Principal::System,
590 CapabilityMask::SYSTEM,
591 )
592 .with_actor(Some(injected));
593 submit.compute(&mut c).expect("injected actor → compute ok");
594 let recorded = c.ops().iter().find_map(|op| match op {
595 arkhe_kernel::state::Op::SetComponent {
596 type_code, bytes, ..
597 } if *type_code == TypeCode(ActivityRecord::TYPE_CODE) => {
598 postcard::from_bytes::<ActivityRecord>(bytes).ok()
599 }
600 _ => None,
601 });
602 assert_eq!(
603 recorded.expect("record present").actor,
604 injected,
605 "recorded author must equal the injected acting actor",
606 );
607 }
608
609 #[test]
610 fn submit_activity_without_injected_actor_rejects() {
611 use crate::context::ActionContext;
612 use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
613
614 let submit = SubmitActivity {
615 schema_version: 1,
616 draft: ActivityDraft {
617 schema_version: 1,
618 shell_id: ShellId([0u8; 16]),
619 verb: VerbCode::canonical(canonical_verbs::LIKE),
620 target: TargetKind::Entry(EntryId::new(ent(2))),
621 at_tick: Tick(0),
622 status: ActivityStatus::Active,
623 extra_bytes: Bytes::new(),
624 },
625 idempotency_key: None,
626 };
627 let mut c = ActionContext::new(
628 [0u8; 32],
629 InstanceId::new(1).unwrap(),
630 Tick(7),
631 Principal::System,
632 CapabilityMask::SYSTEM,
633 );
634 let err = submit
635 .compute(&mut c)
636 .expect_err("no injected actor must reject");
637 assert!(matches!(err, ActionError::AuthorizationFailed(_)));
638 assert!(c.ops().is_empty(), "no Ops on rejection");
639 }
640
641 #[test]
642 fn submit_activity_rejects_wire_schema_mismatch() {
643 use crate::context::ActionContext;
644 use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
645
646 let mut submit = SubmitActivity {
647 schema_version: 0xBEEF,
648 draft: ActivityDraft {
649 schema_version: 1,
650 shell_id: ShellId([0u8; 16]),
651 verb: VerbCode::canonical(canonical_verbs::LIKE),
652 target: TargetKind::Entry(EntryId::new(ent(2))),
653 at_tick: Tick(0),
654 status: ActivityStatus::Active,
655 extra_bytes: Bytes::new(),
656 },
657 idempotency_key: None,
658 };
659 let mut c = ActionContext::new(
660 [0u8; 32],
661 InstanceId::new(1).unwrap(),
662 Tick(7),
663 Principal::System,
664 CapabilityMask::SYSTEM,
665 )
666 .with_actor(Some(ActorId::new(ent(0xAC))));
667
668 let err = submit
670 .compute(&mut c)
671 .expect_err("wire schema mismatch must reject");
672 assert!(
673 matches!(
674 err,
675 ActionError::SchemaMismatch {
676 expected: 1,
677 got: 0xBEEF,
678 }
679 ),
680 "got {err:?}",
681 );
682 assert!(c.ops().is_empty(), "no Ops on rejection");
683
684 submit.schema_version = 1;
687 submit.draft.schema_version = 0xBEEF;
688 let err = submit
689 .compute(&mut c)
690 .expect_err("draft schema mismatch must reject");
691 assert!(
692 matches!(
693 err,
694 ActionError::SchemaMismatch {
695 expected: 1,
696 got: 0xBEEF,
697 }
698 ),
699 "got {err:?}",
700 );
701 assert!(c.ops().is_empty(), "no Ops on rejection");
702
703 submit.draft.schema_version = 1;
705 submit.compute(&mut c).expect("matching versions → Ok");
706 }
707
708 #[test]
709 fn retract_activity_rejects_wire_schema_mismatch() {
710 use crate::context::ActionContext;
711 use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal};
712
713 let mut c = ActionContext::new(
714 [0u8; 32],
715 InstanceId::new(1).unwrap(),
716 Tick(7),
717 Principal::System,
718 CapabilityMask::SYSTEM,
719 );
720 let err = RetractActivity {
721 schema_version: 0xBEEF,
722 activity: ActivityId::new(ent(5)),
723 }
724 .compute(&mut c)
725 .expect_err("wire schema mismatch must reject");
726 assert!(
727 matches!(
728 err,
729 ActionError::SchemaMismatch {
730 expected: 1,
731 got: 0xBEEF,
732 }
733 ),
734 "got {err:?}",
735 );
736
737 RetractActivity {
738 schema_version: 1,
739 activity: ActivityId::new(ent(5)),
740 }
741 .compute(&mut c)
742 .expect("matching version → Ok");
743 }
744
745 #[test]
746 fn activity_types_expose_trait_consts() {
747 assert_eq!(ActivityRecord::TYPE_CODE, 0x0003_0401);
748 assert_eq!(EntityShellId::TYPE_CODE, 0x0003_0402);
749 assert_eq!(SubmitActivity::TYPE_CODE, 0x0001_0401);
750 assert_eq!(SubmitActivity::BAND, 1);
751 assert_eq!(RetractActivity::TYPE_CODE, 0x0001_0402);
752 const { assert!(SubmitActivity::IDEMPOTENT) };
753 const { assert!(!RetractActivity::IDEMPOTENT) };
754 }
755
756 #[test]
757 fn target_kind_key_with_shell_stamps_discriminant_and_shell() {
758 let entry = TargetKind::Entry(EntryId::new(ent(7)));
759 let shell = ShellId([0x33u8; 16]);
760 let k = entry.key_with_shell(shell);
761 assert_eq!(k.kind_code, 1);
762 assert_eq!(k.id.get(), 7);
763 assert_eq!(k.target_shell_id, shell);
764
765 let ext = TargetKind::Extension {
766 type_code: TypeCode(0x0100_0001),
767 id: ent(9),
768 };
769 let k2 = ext.key_with_shell(shell);
770 assert_eq!(k2.kind_code, 5);
771 assert_eq!(k2.type_code, TypeCode(0x0100_0001));
772 }
773
774 #[test]
775 fn appeal_cap_is_eight() {
776 assert_eq!(APPEAL_MAX_DEPTH_CAP, 8);
777 }
778}