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, 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;
26// E14.L1-Deny enforcement on Action::compute.
27use crate::arkhe_pure;
28
29/// Opaque handle into the runtime Activity namespace.
30#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
31#[serde(transparent)]
32pub struct ActivityId(EntityId);
33
34impl ActivityId {
35    /// Construct an `ActivityId` from a runtime-allocated `EntityId`. Callers
36    /// must hold proof (spawn event, admin scope, or test fixture) that the
37    /// id belongs to the Activity namespace — this constructor does not verify.
38    #[inline]
39    #[must_use]
40    pub fn new(id: EntityId) -> Self {
41        Self(id)
42    }
43
44    /// Underlying entity handle.
45    #[inline]
46    #[must_use]
47    pub fn get(self) -> EntityId {
48        self.0
49    }
50}
51
52/// Canonical verb witness — `const C` pinned to `0x0002_0001..=0x0002_03FF`.
53///
54/// Value-level construction is gated: only the module-level constants in
55/// [`canonical_verbs`] (which pin `C` in-range) yield a value. User code
56/// cannot forge an out-of-range `CanonicalVerb<X>`.
57pub struct CanonicalVerb<const C: u32> {
58    _private: (),
59}
60
61/// Shell-extensible verb witness — `const C` pinned to
62/// `0x0002_0400..=0x0002_FFFF`.
63///
64/// Value-level construction is guarded by [`ShellVerb::try_new`], which
65/// checks the const `C` at invocation time; out-of-range values yield
66/// `None`. Shell code typically places the construction inside a `const`
67/// binding so monomorphization fails loudly when the range is violated.
68pub struct ShellVerb<const C: u32> {
69    _private: (),
70}
71
72impl<const C: u32> ShellVerb<C> {
73    /// Construct a `ShellVerb<C>` witness iff `C` lies in the shell verb
74    /// sub-range `0x0002_0400..=0x0002_FFFF`.
75    #[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/// Canonical or shell-extension verb code, wrapped for serialization.
87#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
88#[serde(transparent)]
89pub struct VerbCode(TypeCode);
90
91impl VerbCode {
92    /// Wrap a canonical verb.
93    #[inline]
94    #[must_use]
95    pub const fn canonical<const C: u32>(_witness: CanonicalVerb<C>) -> Self {
96        Self(TypeCode(C))
97    }
98
99    /// Wrap a shell-extensible verb.
100    #[inline]
101    #[must_use]
102    pub const fn shell<const C: u32>(_witness: ShellVerb<C>) -> Self {
103        Self(TypeCode(C))
104    }
105
106    /// Underlying dispatch code.
107    #[inline]
108    #[must_use]
109    pub const fn code(self) -> TypeCode {
110        self.0
111    }
112}
113
114/// ActivityStreams-aligned canonical verb witnesses.
115pub mod canonical_verbs {
116    use super::CanonicalVerb;
117
118    /// Like / upvote.
119    pub const LIKE: CanonicalVerb<0x0002_0001> = CanonicalVerb { _private: () };
120    /// Follow.
121    pub const FOLLOW: CanonicalVerb<0x0002_0002> = CanonicalVerb { _private: () };
122    /// Bookmark.
123    pub const BOOKMARK: CanonicalVerb<0x0002_0003> = CanonicalVerb { _private: () };
124    /// Report (moderation).
125    pub const REPORT: CanonicalVerb<0x0002_0004> = CanonicalVerb { _private: () };
126    /// Mute.
127    pub const MUTE: CanonicalVerb<0x0002_0005> = CanonicalVerb { _private: () };
128    /// Block.
129    pub const BLOCK: CanonicalVerb<0x0002_0006> = CanonicalVerb { _private: () };
130    /// Pin.
131    pub const PIN: CanonicalVerb<0x0002_0007> = CanonicalVerb { _private: () };
132    /// Flag (policy infraction).
133    pub const FLAG: CanonicalVerb<0x0002_0008> = CanonicalVerb { _private: () };
134}
135
136/// Target entity of an Activity. `#[non_exhaustive]` so additional canonical
137/// target families can append.
138///
139/// On-wire tag is the postcard VARIANT INDEX (0..N in declaration order), not
140/// the `repr(u8)` discriminant — the explicit values are a C-ABI hint, never
141/// the serialized byte.
142#[non_exhaustive]
143#[repr(u8)]
144#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
145pub enum TargetKind {
146    /// Entry target.
147    Entry(EntryId) = 0,
148    /// Actor target.
149    Actor(ActorId) = 1,
150    /// Space target.
151    Space(SpaceId) = 2,
152    /// Activity target (meta-verb — flag on a report, etc.).
153    Activity(ActivityId) = 3,
154    /// Shell-defined extension target.
155    Extension {
156        /// Extension dispatch code.
157        type_code: TypeCode,
158        /// Target entity handle.
159        id: EntityId,
160    } = 4,
161}
162
163/// BTreeMap key for Active-Activity idempotency index (E-act-1 / C1).
164///
165/// Includes `target_shell_id` so cross-shell bypass via type-erased entity
166/// id is impossible (E-act-2 dual-tier).
167#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
168pub struct TargetKey {
169    /// `1`=Entry, `2`=Actor, `3`=Space, `4`=Activity, `5`=Extension.
170    pub kind_code: u8,
171    /// `TypeCode(0)` except for Extension targets.
172    pub type_code: TypeCode,
173    /// Underlying entity handle.
174    pub id: EntityId,
175    /// Shell of the target entity — closes brand-bypass gap (E-act-2 MC).
176    pub target_shell_id: ShellId,
177}
178
179impl TargetKind {
180    /// Derive the idempotency key from a target descriptor.
181    ///
182    /// The `ActionContext` is currently unused — once `ctx.read::<C>()`
183    /// ships, the target's shell is resolved from the matching storage
184    /// component (`EntryCore.shell_id`, `ActorProfile.shell_id`,
185    /// `SpaceConfig.shell_id`, `ActivityRecord.shell_id`, or
186    /// `EntityShellId.shell_id` for Extension targets). Until then the
187    /// returns a zeroed `ShellId` for callers
188    /// that need the real shell must pass it explicitly via
189    /// [`TargetKind::key_with_shell`].
190    #[must_use]
191    pub fn key(&self, _ctx: &ActionContext<'_>) -> TargetKey {
192        self.key_with_shell(ShellId([0u8; 16]))
193    }
194
195    /// Build a `TargetKey` using an explicit `target_shell_id`. Useful when
196    /// the caller has already resolved the target's owner (e.g. submit-site
197    /// with a freshly-authenticated `ShellBrand`).
198    #[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/// Marker Component tracking the shell owner of an Extension target entity
217/// (invariant E-act-7, immutable after first SetComponent).
218#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
219#[arkhe(type_code = 0x0003_0402, schema_version = 1)]
220pub struct EntityShellId {
221    /// Wire-level schema version tag.
222    pub schema_version: u16,
223    /// Owning shell.
224    pub shell_id: ShellId,
225}
226
227/// Activity lifecycle status. Retracted entries are tombstoned (E-act-4).
228#[non_exhaustive]
229#[repr(u8)]
230#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
231pub enum ActivityStatus {
232    /// Indexed in the `(actor, verb, target.key())` BTreeMap.
233    Active = 0,
234    /// Removed from the index; preserved as tombstone with retraction tick.
235    Retracted {
236        /// Retraction tick.
237        at: Tick,
238    } = 1,
239}
240
241/// Storage-safe Activity record. `'static`, postcard-DeserializeOwned,
242/// brand-free — this is what instance state stores and observers read
243/// (C1 contract; chain-anchored via the `Submit` record's action bytes
244/// + the `Step` record's post-state digest).
245#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
246#[arkhe(type_code = 0x0003_0401, schema_version = 1)]
247pub struct ActivityRecord {
248    /// Wire-level schema version tag.
249    pub schema_version: u16,
250    /// Shell identity — double-checked on replay vs `ActorProfile.shell_id`.
251    pub shell_id: ShellId,
252    /// Acting actor.
253    pub actor: ActorId,
254    /// Verb code.
255    pub verb: VerbCode,
256    /// Target descriptor.
257    pub target: TargetKind,
258    /// Emission tick.
259    pub at_tick: Tick,
260    /// Lifecycle status.
261    pub status: ActivityStatus,
262    /// Shell-defined opaque payload. Runtime does not interpret — schema
263    /// change implies a new `VerbCode` (E-act-3).
264    pub extra_bytes: Bytes,
265}
266
267/// Runtime-only branded Activity wrapper — used at submit-site for
268/// compile-time shell isolation (C1 contract).
269#[derive(Clone)]
270pub struct Activity<'s> {
271    brand: ShellBrand<'s>,
272    inner: ActivityRecord,
273}
274
275impl<'s> Activity<'s> {
276    /// Construct a branded `Activity<'s>`. Caller supplies the `ShellBrand`
277    /// obtained from [`ShellBrand::run`](crate::brand::ShellBrand::run) — the
278    /// brand's invariant lifetime keeps this primitive from leaking across
279    /// shells at the type level.
280    #[inline]
281    #[must_use]
282    pub fn new(brand: ShellBrand<'s>, inner: ActivityRecord) -> Self {
283        Self { brand, inner }
284    }
285
286    /// Borrow the underlying storage-safe record.
287    #[inline]
288    #[must_use]
289    pub fn inner(&self) -> &ActivityRecord {
290        &self.inner
291    }
292
293    /// Shell brand of this Activity.
294    #[inline]
295    #[must_use]
296    pub fn brand(&self) -> ShellBrand<'s> {
297        self.brand
298    }
299}
300
301/// Wire-format Activity details MINUS the acting actor. The acting actor is
302/// NOT a wire field: the runtime injects the authenticated identity at the
303/// dispatch boundary, and [`SubmitActivity::compute`] stamps it into the
304/// stored [`ActivityRecord`]. This is the structural close of the
305/// actor-substitution surface — there is no client-supplied `actor` to spoof.
306///
307/// The Activity's TARGET (which may reference another actor) stays here: a
308/// target is legitimate data, not the ACTING identity.
309#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
310pub struct ActivityDraft {
311    /// Wire-level schema version tag.
312    pub schema_version: u16,
313    /// Shell identity — double-checked on replay vs `ActorProfile.shell_id`.
314    pub shell_id: ShellId,
315    /// Verb code.
316    pub verb: VerbCode,
317    /// Target descriptor.
318    pub target: TargetKind,
319    /// Emission tick.
320    pub at_tick: Tick,
321    /// Lifecycle status.
322    pub status: ActivityStatus,
323    /// Shell-defined opaque payload. Runtime does not interpret — schema
324    /// change implies a new `VerbCode` (E-act-3).
325    pub extra_bytes: Bytes,
326}
327
328impl ActivityDraft {
329    /// Promote a draft to a stored [`ActivityRecord`] by stamping the
330    /// authenticated acting `actor` — the single source of truth injected by
331    /// the runtime, never a wire field.
332    #[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/// Submit an Activity to the runtime. Brand-less wire format — the branded
348/// `Activity<'s>` is unwrapped at submit-site via [`SubmitActivity::from_branded`].
349///
350/// The payload carries no acting actor: the recorded author is the
351/// authenticated identity the runtime injects via
352/// [`ActionContext::acting_actor`](crate::context::ActionContext::acting_actor),
353/// so it cannot be substituted.
354#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
355#[arkhe(type_code = 0x0001_0401, schema_version = 1, band = 1, idempotent)]
356pub struct SubmitActivity {
357    /// Wire-level schema version tag.
358    pub schema_version: u16,
359    /// Activity details minus the acting actor (injected at dispatch).
360    pub draft: ActivityDraft,
361    /// Opt-in idempotency key. `None` = non-idempotent.
362    pub idempotency_key: Option<[u8; 16]>,
363}
364
365impl SubmitActivity {
366    /// Convert a branded `Activity<'s>` to a brand-less submit payload. The
367    /// branded record's `actor` is dropped — the acting identity is injected
368    /// by the runtime at dispatch, not carried from the submit site.
369    #[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    /// Attach an idempotency key.
389    #[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/// Retract an existing Activity — tombstones its record and removes it from
398/// the `(actor, verb, target.key())` idempotency index (E-act-4).
399#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
400#[arkhe(type_code = 0x0001_0402, schema_version = 1, band = 1)]
401pub struct RetractActivity {
402    /// Wire-level schema version tag.
403    pub schema_version: u16,
404    /// Target Activity handle.
405    pub activity: ActivityId,
406}
407
408impl ActionCompute for SubmitActivity {
409    #[arkhe_pure]
410    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
411        // Validate-then-copy: wire schema versions are checked against the
412        // canonical constants before any other gate, so a stale or forged
413        // version never reaches the stored record.
414        ensure_schema_version(Self::SCHEMA_VERSION, self.schema_version)?;
415        ensure_schema_version(ActivityRecord::SCHEMA_VERSION, self.draft.schema_version)?;
416
417        // Single source of truth: the acting actor is the authenticated
418        // identity the runtime injected at dispatch — never a wire field.
419        // A user-scoped action with no injected actor cannot proceed.
420        let actor = ctx.acting_actor().ok_or(ActionError::AuthorizationFailed(
421            "activity requires an authenticated actor",
422        ))?;
423
424        // E-user-3 C3 MC — refuse if the actor's backing user is in
425        // `GdprStatus::ErasurePending`. `GdprEraseUser` owns the write that
426        // sets that pointer (its own `UserGdprState` component).
427        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        // E-act-5 MC — self-loop rejection. The `preview_next_id_for` peek
436        // yields the `EntityId` that the upcoming `spawn_entity_for` call
437        // will produce; if the activity targets itself we refuse before
438        // any `Op` is pushed to the buffer (E-act-5 invariant).
439        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        // Stamp the injected actor into the stored record (authenticated
447        // author), then spawn + attach.
448        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        // The host reads the existing `ActivityRecord` via
460        // `ctx.read::<C>(activity_id)`, produces a tombstone variant, and
461        // pushes `Op::SetComponent` with the updated status + index delta.
462        Ok(())
463    }
464}
465
466/// Hard cap on meta-verb depth — the runtime WAL bound on
467/// `manifest.moderation.appeal_max_depth` (E-act-5). Shell manifest values must
468/// fall in `1..=APPEAL_MAX_DEPTH_CAP`.
469pub 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        // The branded record carries an `actor`, but `from_branded` produces a
523        // draft with NO actor field — the acting identity is injected at
524        // dispatch, not carried from the submit site.
525        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            // Everything but the acting actor is preserved into the draft.
539            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        // Action-level wire field — first check, fires before the auth gate.
669        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        // Nested draft field — same gate, validated before the copy into
685        // the stored ActivityRecord.
686        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        // Matching versions proceed.
704        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}