Skip to main content

arkhe_forge_core/
activity.rs

1//! Activity primitive — actor→target verb.
2//!
3//! Three-layer split: ActivityRecord / Activity / SubmitActivity
4//! - [`ActivityRecord`] — storage-safe Component, `'static`, postcard-canonical.
5//! - [`Activity<'s>`] — runtime-only branded wrapper used at submit-site for
6//!   compile-time shell isolation.
7//! - [`SubmitActivity`] — brand-less Action consumed by L1 compute.
8//!
9//! Verb codes live in two sub-ranges: canonical (`0x0002_0001..=0x0002_03FF`,
10//! 1023 slots) and shell-extensible (`0x0002_0400..=0x0002_FFFF`, BLAKE3-derived
11//! 256-verb sub-range per manifest).
12
13use 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;
25// E14.L1-Deny enforcement on Action::compute.
26use crate::arkhe_pure;
27
28/// Opaque handle into the runtime Activity namespace.
29#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
30#[serde(transparent)]
31pub struct ActivityId(EntityId);
32
33impl ActivityId {
34    /// Construct an `ActivityId` from a runtime-allocated `EntityId`. Callers
35    /// must hold proof (spawn event, admin scope, or test fixture) that the
36    /// id belongs to the Activity namespace — this constructor does not verify.
37    #[inline]
38    #[must_use]
39    pub fn new(id: EntityId) -> Self {
40        Self(id)
41    }
42
43    /// Underlying entity handle.
44    #[inline]
45    #[must_use]
46    pub fn get(self) -> EntityId {
47        self.0
48    }
49}
50
51/// Canonical verb witness — `const C` pinned to `0x0002_0001..=0x0002_03FF`.
52///
53/// Value-level construction is gated: only the module-level constants in
54/// [`canonical_verbs`] (which pin `C` in-range) yield a value. User code
55/// cannot forge an out-of-range `CanonicalVerb<X>`.
56pub struct CanonicalVerb<const C: u32> {
57    _private: (),
58}
59
60/// Shell-extensible verb witness — `const C` pinned to
61/// `0x0002_0400..=0x0002_FFFF`.
62///
63/// Value-level construction is guarded by [`ShellVerb::try_new`], which
64/// checks the const `C` at invocation time; out-of-range values yield
65/// `None`. Shell code typically places the construction inside a `const`
66/// binding so monomorphization fails loudly when the range is violated.
67pub struct ShellVerb<const C: u32> {
68    _private: (),
69}
70
71impl<const C: u32> ShellVerb<C> {
72    /// Construct a `ShellVerb<C>` witness iff `C` lies in the shell verb
73    /// sub-range `0x0002_0400..=0x0002_FFFF`.
74    #[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/// Canonical or shell-extension verb code, wrapped for serialization.
86#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
87#[serde(transparent)]
88pub struct VerbCode(TypeCode);
89
90impl VerbCode {
91    /// Wrap a canonical verb.
92    #[inline]
93    #[must_use]
94    pub const fn canonical<const C: u32>(_witness: CanonicalVerb<C>) -> Self {
95        Self(TypeCode(C))
96    }
97
98    /// Wrap a shell-extensible verb.
99    #[inline]
100    #[must_use]
101    pub const fn shell<const C: u32>(_witness: ShellVerb<C>) -> Self {
102        Self(TypeCode(C))
103    }
104
105    /// Underlying dispatch code.
106    #[inline]
107    #[must_use]
108    pub const fn code(self) -> TypeCode {
109        self.0
110    }
111}
112
113/// ActivityStreams-aligned canonical verb witnesses.
114pub mod canonical_verbs {
115    use super::CanonicalVerb;
116
117    /// Like / upvote.
118    pub const LIKE: CanonicalVerb<0x0002_0001> = CanonicalVerb { _private: () };
119    /// Follow.
120    pub const FOLLOW: CanonicalVerb<0x0002_0002> = CanonicalVerb { _private: () };
121    /// Bookmark.
122    pub const BOOKMARK: CanonicalVerb<0x0002_0003> = CanonicalVerb { _private: () };
123    /// Report (moderation).
124    pub const REPORT: CanonicalVerb<0x0002_0004> = CanonicalVerb { _private: () };
125    /// Mute.
126    pub const MUTE: CanonicalVerb<0x0002_0005> = CanonicalVerb { _private: () };
127    /// Block.
128    pub const BLOCK: CanonicalVerb<0x0002_0006> = CanonicalVerb { _private: () };
129    /// Pin.
130    pub const PIN: CanonicalVerb<0x0002_0007> = CanonicalVerb { _private: () };
131    /// Flag (policy infraction).
132    pub const FLAG: CanonicalVerb<0x0002_0008> = CanonicalVerb { _private: () };
133}
134
135/// Target entity of an Activity. `#[non_exhaustive]` so additional canonical
136/// target families can append.
137#[non_exhaustive]
138#[repr(u8)]
139#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
140pub enum TargetKind {
141    /// Entry target.
142    Entry(EntryId) = 0,
143    /// Actor target.
144    Actor(ActorId) = 1,
145    /// Space target.
146    Space(SpaceId) = 2,
147    /// Activity target (meta-verb — flag on a report, etc.).
148    Activity(ActivityId) = 3,
149    /// Shell-defined extension target.
150    Extension {
151        /// Extension dispatch code.
152        type_code: TypeCode,
153        /// Target entity handle.
154        id: EntityId,
155    } = 4,
156}
157
158/// BTreeMap key for Active-Activity idempotency index (E-act-1 / C1).
159///
160/// Includes `target_shell_id` so cross-shell bypass via type-erased entity
161/// id is impossible (E-act-2 dual-tier).
162#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
163pub struct TargetKey {
164    /// `1`=Entry, `2`=Actor, `3`=Space, `4`=Activity, `5`=Extension.
165    pub kind_code: u8,
166    /// `TypeCode(0)` except for Extension targets.
167    pub type_code: TypeCode,
168    /// Underlying entity handle.
169    pub id: EntityId,
170    /// Shell of the target entity — closes brand-bypass gap (E-act-2 MC).
171    pub target_shell_id: ShellId,
172}
173
174impl TargetKind {
175    /// Derive the idempotency key from a target descriptor.
176    ///
177    /// The `ActionContext` is currently unused — once `ctx.read::<C>()`
178    /// ships, the target's shell is resolved from the matching storage
179    /// component (`EntryCore.shell_id`, `ActorProfile.shell_id`,
180    /// `SpaceConfig.shell_id`, `ActivityRecord.shell_id`, or
181    /// `EntityShellId.shell_id` for Extension targets). Until then the
182    /// returns a zeroed `ShellId` for callers
183    /// that need the real shell must pass it explicitly via
184    /// [`TargetKind::key_with_shell`].
185    #[must_use]
186    pub fn key(&self, _ctx: &ActionContext<'_>) -> TargetKey {
187        self.key_with_shell(ShellId([0u8; 16]))
188    }
189
190    /// Build a `TargetKey` using an explicit `target_shell_id`. Useful when
191    /// the caller has already resolved the target's owner (e.g. submit-site
192    /// with a freshly-authenticated `ShellBrand`).
193    #[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/// Marker Component tracking the shell owner of an Extension target entity
212/// (invariant E-act-7, immutable after first SetComponent).
213#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
214#[arkhe(type_code = 0x0003_0402, schema_version = 1)]
215pub struct EntityShellId {
216    /// Wire-level schema version tag.
217    pub schema_version: u16,
218    /// Owning shell.
219    pub shell_id: ShellId,
220}
221
222/// Activity lifecycle status. Retracted entries are tombstoned (E-act-4).
223#[non_exhaustive]
224#[repr(u8)]
225#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
226pub enum ActivityStatus {
227    /// Indexed in the `(actor, verb, target.key())` BTreeMap.
228    Active = 0,
229    /// Removed from the index; preserved as tombstone with retraction tick.
230    Retracted {
231        /// Retraction tick.
232        at: Tick,
233    } = 1,
234}
235
236/// Storage-safe Activity record. `'static`, postcard-DeserializeOwned,
237/// brand-free — this is what the WAL stores and observers read (C1 contract).
238#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
239#[arkhe(type_code = 0x0003_0401, schema_version = 1)]
240pub struct ActivityRecord {
241    /// Wire-level schema version tag.
242    pub schema_version: u16,
243    /// Shell identity — double-checked on replay vs `ActorProfile.shell_id`.
244    pub shell_id: ShellId,
245    /// Acting actor.
246    pub actor: ActorId,
247    /// Verb code.
248    pub verb: VerbCode,
249    /// Target descriptor.
250    pub target: TargetKind,
251    /// Emission tick.
252    pub at_tick: Tick,
253    /// Lifecycle status.
254    pub status: ActivityStatus,
255    /// Shell-defined opaque payload. Runtime does not interpret — schema
256    /// change implies a new `VerbCode` (E-act-3).
257    pub extra_bytes: Bytes,
258}
259
260/// Runtime-only branded Activity wrapper — used at submit-site for
261/// compile-time shell isolation (C1 contract).
262#[derive(Clone)]
263pub struct Activity<'s> {
264    brand: ShellBrand<'s>,
265    inner: ActivityRecord,
266}
267
268impl<'s> Activity<'s> {
269    /// Construct a branded `Activity<'s>`. Caller supplies the `ShellBrand`
270    /// obtained from [`ShellBrand::run`](crate::brand::ShellBrand::run) — the
271    /// brand's invariant lifetime keeps this primitive from leaking across
272    /// shells at the type level.
273    #[inline]
274    #[must_use]
275    pub fn new(brand: ShellBrand<'s>, inner: ActivityRecord) -> Self {
276        Self { brand, inner }
277    }
278
279    /// Borrow the underlying storage-safe record.
280    #[inline]
281    #[must_use]
282    pub fn inner(&self) -> &ActivityRecord {
283        &self.inner
284    }
285
286    /// Shell brand of this Activity.
287    #[inline]
288    #[must_use]
289    pub fn brand(&self) -> ShellBrand<'s> {
290        self.brand
291    }
292}
293
294/// Submit an Activity to the runtime. Brand-less wire format — the branded
295/// `Activity<'s>` is unwrapped at submit-site via [`SubmitActivity::from_branded`].
296#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
297#[arkhe(type_code = 0x0001_0401, schema_version = 1, band = 1, idempotent)]
298pub struct SubmitActivity {
299    /// Wire-level schema version tag.
300    pub schema_version: u16,
301    /// Record payload.
302    pub record: ActivityRecord,
303    /// Opt-in idempotency key. `None` = non-idempotent.
304    pub idempotency_key: Option<[u8; 16]>,
305}
306
307impl SubmitActivity {
308    /// Convert a branded `Activity<'s>` to a brand-less submit payload.
309    #[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    /// Attach an idempotency key.
320    #[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/// Retract an existing Activity — tombstones its record and removes it from
329/// the `(actor, verb, target.key())` idempotency index (E-act-4).
330#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
331#[arkhe(type_code = 0x0001_0402, schema_version = 1, band = 1)]
332pub struct RetractActivity {
333    /// Wire-level schema version tag.
334    pub schema_version: u16,
335    /// Target Activity handle.
336    pub activity: ActivityId,
337}
338
339impl ActionCompute for SubmitActivity {
340    #[arkhe_pure]
341    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
342        // E-user-3 C3 MC — refuse if the actor's backing user is in
343        // `GdprStatus::ErasurePending`. Cascade owns the only legal write
344        // path until completion (C3 contract).
345        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        // E-act-5 MC — self-loop rejection. The `preview_next_id_for` peek
354        // yields the `EntityId` that the upcoming `spawn_entity_for` call
355        // will produce; if the activity targets itself we refuse before
356        // any `Op` is pushed to the buffer (E-act-5 invariant).
357        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        // The host reads the existing `ActivityRecord` via
374        // `ctx.read::<C>(activity_id)`, produces a tombstone variant, and
375        // pushes `Op::SetComponent` with the updated status + index delta.
376        Ok(())
377    }
378}
379
380/// Hard cap on meta-verb depth — the runtime WAL bound on
381/// `manifest.moderation.appeal_max_depth` (E-act-5). Shell manifest values must
382/// fall in `1..=APPEAL_MAX_DEPTH_CAP`.
383pub 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}