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///
138/// On-wire tag is the postcard VARIANT INDEX (0..N in declaration order), not
139/// the `repr(u8)` discriminant — the explicit values are a C-ABI hint, never
140/// the serialized byte.
141#[non_exhaustive]
142#[repr(u8)]
143#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
144pub enum TargetKind {
145    /// Entry target.
146    Entry(EntryId) = 0,
147    /// Actor target.
148    Actor(ActorId) = 1,
149    /// Space target.
150    Space(SpaceId) = 2,
151    /// Activity target (meta-verb — flag on a report, etc.).
152    Activity(ActivityId) = 3,
153    /// Shell-defined extension target.
154    Extension {
155        /// Extension dispatch code.
156        type_code: TypeCode,
157        /// Target entity handle.
158        id: EntityId,
159    } = 4,
160}
161
162/// BTreeMap key for Active-Activity idempotency index (E-act-1 / C1).
163///
164/// Includes `target_shell_id` so cross-shell bypass via type-erased entity
165/// id is impossible (E-act-2 dual-tier).
166#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Serialize, Deserialize)]
167pub struct TargetKey {
168    /// `1`=Entry, `2`=Actor, `3`=Space, `4`=Activity, `5`=Extension.
169    pub kind_code: u8,
170    /// `TypeCode(0)` except for Extension targets.
171    pub type_code: TypeCode,
172    /// Underlying entity handle.
173    pub id: EntityId,
174    /// Shell of the target entity — closes brand-bypass gap (E-act-2 MC).
175    pub target_shell_id: ShellId,
176}
177
178impl TargetKind {
179    /// Derive the idempotency key from a target descriptor.
180    ///
181    /// The `ActionContext` is currently unused — once `ctx.read::<C>()`
182    /// ships, the target's shell is resolved from the matching storage
183    /// component (`EntryCore.shell_id`, `ActorProfile.shell_id`,
184    /// `SpaceConfig.shell_id`, `ActivityRecord.shell_id`, or
185    /// `EntityShellId.shell_id` for Extension targets). Until then the
186    /// returns a zeroed `ShellId` for callers
187    /// that need the real shell must pass it explicitly via
188    /// [`TargetKind::key_with_shell`].
189    #[must_use]
190    pub fn key(&self, _ctx: &ActionContext<'_>) -> TargetKey {
191        self.key_with_shell(ShellId([0u8; 16]))
192    }
193
194    /// Build a `TargetKey` using an explicit `target_shell_id`. Useful when
195    /// the caller has already resolved the target's owner (e.g. submit-site
196    /// with a freshly-authenticated `ShellBrand`).
197    #[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/// Marker Component tracking the shell owner of an Extension target entity
216/// (invariant E-act-7, immutable after first SetComponent).
217#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
218#[arkhe(type_code = 0x0003_0402, schema_version = 1)]
219pub struct EntityShellId {
220    /// Wire-level schema version tag.
221    pub schema_version: u16,
222    /// Owning shell.
223    pub shell_id: ShellId,
224}
225
226/// Activity lifecycle status. Retracted entries are tombstoned (E-act-4).
227#[non_exhaustive]
228#[repr(u8)]
229#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
230pub enum ActivityStatus {
231    /// Indexed in the `(actor, verb, target.key())` BTreeMap.
232    Active = 0,
233    /// Removed from the index; preserved as tombstone with retraction tick.
234    Retracted {
235        /// Retraction tick.
236        at: Tick,
237    } = 1,
238}
239
240/// Storage-safe Activity record. `'static`, postcard-DeserializeOwned,
241/// brand-free — this is what the WAL stores and observers read (C1 contract).
242#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheComponent)]
243#[arkhe(type_code = 0x0003_0401, schema_version = 1)]
244pub struct ActivityRecord {
245    /// Wire-level schema version tag.
246    pub schema_version: u16,
247    /// Shell identity — double-checked on replay vs `ActorProfile.shell_id`.
248    pub shell_id: ShellId,
249    /// Acting actor.
250    pub actor: ActorId,
251    /// Verb code.
252    pub verb: VerbCode,
253    /// Target descriptor.
254    pub target: TargetKind,
255    /// Emission tick.
256    pub at_tick: Tick,
257    /// Lifecycle status.
258    pub status: ActivityStatus,
259    /// Shell-defined opaque payload. Runtime does not interpret — schema
260    /// change implies a new `VerbCode` (E-act-3).
261    pub extra_bytes: Bytes,
262}
263
264/// Runtime-only branded Activity wrapper — used at submit-site for
265/// compile-time shell isolation (C1 contract).
266#[derive(Clone)]
267pub struct Activity<'s> {
268    brand: ShellBrand<'s>,
269    inner: ActivityRecord,
270}
271
272impl<'s> Activity<'s> {
273    /// Construct a branded `Activity<'s>`. Caller supplies the `ShellBrand`
274    /// obtained from [`ShellBrand::run`](crate::brand::ShellBrand::run) — the
275    /// brand's invariant lifetime keeps this primitive from leaking across
276    /// shells at the type level.
277    #[inline]
278    #[must_use]
279    pub fn new(brand: ShellBrand<'s>, inner: ActivityRecord) -> Self {
280        Self { brand, inner }
281    }
282
283    /// Borrow the underlying storage-safe record.
284    #[inline]
285    #[must_use]
286    pub fn inner(&self) -> &ActivityRecord {
287        &self.inner
288    }
289
290    /// Shell brand of this Activity.
291    #[inline]
292    #[must_use]
293    pub fn brand(&self) -> ShellBrand<'s> {
294        self.brand
295    }
296}
297
298/// Wire-format Activity details MINUS the acting actor. The acting actor is
299/// NOT a wire field: the runtime injects the authenticated identity at the
300/// dispatch boundary, and [`SubmitActivity::compute`] stamps it into the
301/// stored [`ActivityRecord`]. This is the structural close of the
302/// actor-substitution surface — there is no client-supplied `actor` to spoof.
303///
304/// The Activity's TARGET (which may reference another actor) stays here: a
305/// target is legitimate data, not the ACTING identity.
306#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
307pub struct ActivityDraft {
308    /// Wire-level schema version tag.
309    pub schema_version: u16,
310    /// Shell identity — double-checked on replay vs `ActorProfile.shell_id`.
311    pub shell_id: ShellId,
312    /// Verb code.
313    pub verb: VerbCode,
314    /// Target descriptor.
315    pub target: TargetKind,
316    /// Emission tick.
317    pub at_tick: Tick,
318    /// Lifecycle status.
319    pub status: ActivityStatus,
320    /// Shell-defined opaque payload. Runtime does not interpret — schema
321    /// change implies a new `VerbCode` (E-act-3).
322    pub extra_bytes: Bytes,
323}
324
325impl ActivityDraft {
326    /// Promote a draft to a stored [`ActivityRecord`] by stamping the
327    /// authenticated acting `actor` — the single source of truth injected by
328    /// the runtime, never a wire field.
329    #[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/// Submit an Activity to the runtime. Brand-less wire format — the branded
345/// `Activity<'s>` is unwrapped at submit-site via [`SubmitActivity::from_branded`].
346///
347/// The payload carries no acting actor: the recorded author is the
348/// authenticated identity the runtime injects via
349/// [`ActionContext::acting_actor`](crate::context::ActionContext::acting_actor),
350/// so it cannot be substituted.
351#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
352#[arkhe(type_code = 0x0001_0401, schema_version = 1, band = 1, idempotent)]
353pub struct SubmitActivity {
354    /// Wire-level schema version tag.
355    pub schema_version: u16,
356    /// Activity details minus the acting actor (injected at dispatch).
357    pub draft: ActivityDraft,
358    /// Opt-in idempotency key. `None` = non-idempotent.
359    pub idempotency_key: Option<[u8; 16]>,
360}
361
362impl SubmitActivity {
363    /// Convert a branded `Activity<'s>` to a brand-less submit payload. The
364    /// branded record's `actor` is dropped — the acting identity is injected
365    /// by the runtime at dispatch, not carried from the submit site.
366    #[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    /// Attach an idempotency key.
386    #[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/// Retract an existing Activity — tombstones its record and removes it from
395/// the `(actor, verb, target.key())` idempotency index (E-act-4).
396#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, ArkheAction)]
397#[arkhe(type_code = 0x0001_0402, schema_version = 1, band = 1)]
398pub struct RetractActivity {
399    /// Wire-level schema version tag.
400    pub schema_version: u16,
401    /// Target Activity handle.
402    pub activity: ActivityId,
403}
404
405impl ActionCompute for SubmitActivity {
406    #[arkhe_pure]
407    fn compute<'i>(&self, ctx: &mut ActionContext<'i>) -> Result<(), ActionError> {
408        // Single source of truth: the acting actor is the authenticated
409        // identity the runtime injected at dispatch — never a wire field.
410        // A user-scoped action with no injected actor cannot proceed.
411        let actor = ctx.acting_actor().ok_or(ActionError::AuthorizationFailed(
412            "activity requires an authenticated actor",
413        ))?;
414
415        // E-user-3 C3 MC — refuse if the actor's backing user is in
416        // `GdprStatus::ErasurePending`. `GdprEraseUser` owns the write that
417        // sets that pointer (its own `UserGdprState` component).
418        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        // E-act-5 MC — self-loop rejection. The `preview_next_id_for` peek
427        // yields the `EntityId` that the upcoming `spawn_entity_for` call
428        // will produce; if the activity targets itself we refuse before
429        // any `Op` is pushed to the buffer (E-act-5 invariant).
430        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        // Stamp the injected actor into the stored record (authenticated
438        // author), then spawn + attach.
439        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        // The host reads the existing `ActivityRecord` via
450        // `ctx.read::<C>(activity_id)`, produces a tombstone variant, and
451        // pushes `Op::SetComponent` with the updated status + index delta.
452        Ok(())
453    }
454}
455
456/// Hard cap on meta-verb depth — the runtime WAL bound on
457/// `manifest.moderation.appeal_max_depth` (E-act-5). Shell manifest values must
458/// fall in `1..=APPEAL_MAX_DEPTH_CAP`.
459pub 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        // The branded record carries an `actor`, but `from_branded` produces a
513        // draft with NO actor field — the acting identity is injected at
514        // dispatch, not carried from the submit site.
515        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            // Everything but the acting actor is preserved into the draft.
529            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}