arkhe_forge_core/bridge.rs
1//! L0 ↔ Forge `ActionCompute` bridge.
2//!
3//! `#[derive(ArkheAction)]` emits a kernel-side
4//! [`arkhe_kernel::state::traits::ActionCompute`] impl whose body
5//! delegates to [`kernel_compute`] (this module). The bridge
6//! reconstructs a forge [`ActionContext`] from the kernel's read-only
7//! view, runs the forge compute body, and drains the resulting
8//! `Vec<Op>` back to the kernel for its authorize → dispatch → WAL
9//! append loop in `Kernel::step`.
10//!
11//! ## Known limitations
12//!
13//! These are the L0-surface gaps that the published kernel API leaves
14//! unaddressed; the bridge documents them honestly rather than
15//! papering over with optimistic framing.
16//!
17//! 1. **`world_seed = [0u8; 32]` placeholder.** The kernel does not
18//! expose `Instance::world_seed` to external callers, so
19//! id-derivation through [`ActionContext::next_id`] produces ids
20//! stable per `(instance_id, type_code, tick, seq)` but not
21//! per-world. The reference `RecordHandShowdown` action in
22//! `examples/card_primitives` does not call `next_id`, so the
23//! limitation is inert for the published demo. `Instance::world_seed`
24//! is not exposed through the kernel `ActionContext` accessor.
25//!
26//! 2. **Principal / capabilities pinned to
27//! `Principal::System` / `CapabilityMask::SYSTEM` here.** The
28//! kernel re-authorizes every drained `Op` against the
29//! caller-supplied caps in `Kernel::step`, so the bridge's pinned
30//! values cannot relax the security gate — they are local to the
31//! forge-side compute body. A forge compute that branches on
32//! [`ActionContext::principal`] will see `System`. The caller
33//! principal is not exposed through the kernel `ActionContext`
34//! accessor.
35//!
36//! 3. **Forge `compute()` returning `Err(ActionError)` is suppressed
37//! to an empty `Vec<Op>`.** The kernel sees an action that
38//! produced no Ops; the `WalRecord` envelope still records the
39//! submission but with empty `stage.events`. A future release that
40//! surfaces the rejection via a dedicated `EffectFailed` kernel
41//! event will let callers distinguish "action rejected" from
42//! "action accepted but no-op". The audit-completeness gap
43//! (rejections invisible in the WAL stream) is tracked as a
44//! future hardening carry.
45//!
46//! 4. **Viewless context — the in-compute GDPR `ErasurePending` gate
47//! soft-passes here.** The bridge builds an
48//! `ActionContext` with no bound `InstanceView` (the kernel exposes
49//! no per-entity read into compute), so
50//! [`ActionContext::ensure_actor_eligible`] cannot resolve the
51//! actor's `UserBinding` / `UserGdprState` and returns `Ok` (soft
52//! pass). This is NOT a hole: the E-user-3 C3 gate is enforced at
53//! the L2 boundary by the `RuntimeService::dispatch` admission gate
54//! (forge-platform), which runs the same `ensure_actor_eligible`
55//! check on the injected authenticated actor against a bound kernel
56//! `InstanceView` BEFORE `submit` — rejecting an erasure-pending
57//! action before it reaches the WAL. The bridge DOES inject that
58//! authenticated actor (the kernel-threaded `KernelActionContext::actor`)
59//! as the forge [`ActionContext::acting_actor`], so a user-scoped
60//! compute records the authenticated identity rather than a wire
61//! field. The direct `ActionContext::new(...).with_view(&view)` path
62//! (non-dispatch callers) enforces the eligibility gate in-compute
63//! because it binds a view.
64//!
65//! ## Caller preconditions
66//!
67//! The bridge is currently scoped to a narrow forge-action shape; the
68//! preconditions below are not enforced at compile time but are
69//! documented contract requirements for any forge action driven
70//! through `RuntimeService`:
71//!
72//! - **Determinism band must be `1` (Core).** Kernel-side `ActionDeriv`
73//! does not propagate forge `BAND` / `IDEMPOTENT` metadata, so a
74//! `BAND = 2` (Projection) or `BAND = 3` (Protocol) action would
75//! dispatch through the same kernel path as Core, breaking
76//! forge-side band-specific dispatch invariants. A future release
77//! wires band-aware kernel routing.
78//!
79//! - **`IDEMPOTENT` must be `false`.** Idempotent forge actions
80//! require the kernel-side
81//! [`IdempotencyIndex`](crate::context::IdempotencyIndex) integration
82//! (production fix: PG-UNIQUE-INDEX) before they can
83//! flow through this bridge safely.
84//!
85//! - **`compute()` body must not branch on
86//! [`ActionContext::principal`] / [`ActionContext::caps`] / the
87//! `world_seed`.** The bridge pins these to constants (limitation
88//! 2 above + the zero `world_seed`); branching on them would force
89//! a single replay path regardless of kernel-side caller intent,
90//! masking principal-aware behaviour. A future release exposes the
91//! kernel principal / caps through the bridge.
92
93use arkhe_kernel::abi::{CapabilityMask, EntityId, InstanceId, Principal, Tick};
94use arkhe_kernel::state::{ActionContext as KernelActionContext, Op};
95
96use crate::action::ActionCompute;
97use crate::actor::ActorId;
98use crate::context::ActionContext;
99
100/// Bridge entry point invoked by the kernel-side `ActionCompute::compute`
101/// impl emitted by `#[derive(ArkheAction)]`.
102///
103/// See the [module-level docs](self) for the known limitations
104/// (`world_seed = 0`, principal pinning, error suppression).
105pub fn kernel_compute<A>(action: &A, kernel_ctx: &KernelActionContext<'_>) -> Vec<Op>
106where
107 A: ActionCompute,
108{
109 kernel_compute_inner(
110 action,
111 kernel_ctx.instance_id,
112 kernel_ctx.now,
113 kernel_ctx.actor,
114 )
115}
116
117/// Testable inner helper — split out so the bridge can be unit-tested
118/// without reaching for a `KernelActionContext`, whose constructor is
119/// `pub(crate)` in the kernel and therefore unreachable from
120/// `arkhe-forge-core`.
121///
122/// `actor` is the kernel-threaded acting identity (the value passed to
123/// `Kernel::submit`, recorded into the WAL record + replayed into
124/// `KernelActionContext::actor`). The bridge injects it as the forge
125/// [`ActionContext::acting_actor`] so a user-scoped compute reads the
126/// authenticated identity rather than a wire payload field.
127fn kernel_compute_inner<A>(
128 action: &A,
129 instance_id: InstanceId,
130 now: Tick,
131 actor: Option<EntityId>,
132) -> Vec<Op>
133where
134 A: ActionCompute,
135{
136 let mut forge_ctx = ActionContext::new(
137 [0u8; 32],
138 instance_id,
139 now,
140 Principal::System,
141 CapabilityMask::SYSTEM,
142 )
143 .with_actor(actor.map(ActorId::new));
144 if <A as ActionCompute>::compute(action, &mut forge_ctx).is_ok() {
145 forge_ctx.drain_ops()
146 } else {
147 Vec::new()
148 }
149}
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
153mod tests {
154 use super::*;
155 use arkhe_kernel::abi::{EntityId, TypeCode};
156
157 use crate::component::ArkheComponent as _;
158 use crate::event::ArkheEvent as _;
159 use crate::event::UserErasureScheduled;
160 use crate::user::{
161 AuthCredential, AuthKind, GdprEraseUser, GdprStatus, KdfKind, KdfParams, RegisterUser,
162 UserGdprState, UserId, UserProfile,
163 };
164
165 fn fixture_args() -> (InstanceId, Tick) {
166 (InstanceId::new(7).unwrap(), Tick(99))
167 }
168
169 #[test]
170 fn ok_compute_returns_drained_ops() {
171 let (iid, tick) = fixture_args();
172 let target = EntityId::new(42).unwrap();
173 let action = GdprEraseUser {
174 schema_version: 1,
175 user: UserId::new(target),
176 };
177 let ops = kernel_compute_inner(&action, iid, tick, None);
178 assert_eq!(
179 ops.len(),
180 2,
181 "GdprEraseUser writes UserGdprState then emits the schedule event",
182 );
183 // op 0 — blind ErasurePending write on the user entity; this is what
184 // makes the L2 admission gate live (no read needed -> viewless-safe).
185 match &ops[0] {
186 Op::SetComponent {
187 entity,
188 type_code,
189 bytes,
190 ..
191 } => {
192 assert_eq!(*entity, target);
193 assert_eq!(*type_code, TypeCode(UserGdprState::TYPE_CODE));
194 let state: UserGdprState = postcard::from_bytes(bytes).unwrap();
195 assert_eq!(state.status, GdprStatus::ErasurePending);
196 }
197 other => panic!("expected SetComponent(UserGdprState), got {:?}", other),
198 }
199 // op 1 — the cascade lease event.
200 match &ops[1] {
201 Op::EmitEvent {
202 actor,
203 event_type_code,
204 event_bytes: _,
205 } => {
206 assert!(actor.is_none());
207 assert_eq!(*event_type_code, TypeCode(UserErasureScheduled::TYPE_CODE));
208 }
209 other => panic!("expected EmitEvent, got {:?}", other),
210 }
211 }
212
213 #[test]
214 fn err_compute_returns_empty_vec() {
215 let (iid, tick) = fixture_args();
216 // RegisterUser with sub-baseline KDF params is rejected by the
217 // forge compute body (see `arkhe-forge-core/src/pipeline.rs`
218 // tests). The bridge must collapse that `Err` to an empty
219 // `Vec<Op>` — kernel sees a no-op submission.
220 let action = RegisterUser {
221 schema_version: 1,
222 profile: UserProfile {
223 schema_version: 1,
224 created_tick: Tick(0),
225 primary_auth_kind: AuthKind::Passkey,
226 },
227 credential: AuthCredential {
228 schema_version: 1,
229 kind: AuthKind::Passkey,
230 kdf: KdfKind::Argon2id,
231 salt: [0u8; 16],
232 credential_hash: [0u8; 32],
233 kdf_params: KdfParams {
234 m_cost: 1024,
235 t_cost: 1,
236 p_cost: 1,
237 },
238 expires_tick: None,
239 bound_tick: Tick(0),
240 },
241 };
242 let ops = kernel_compute_inner(&action, iid, tick, None);
243 assert!(
244 ops.is_empty(),
245 "weak-KDF RegisterUser must collapse to empty Op vec",
246 );
247 }
248
249 #[test]
250 fn injected_actor_reaches_compute_as_activity_author() {
251 // A+ proof: the kernel-threaded actor (the value passed to
252 // `Kernel::submit`) is injected as the forge `acting_actor`, and a
253 // user-scoped `SubmitActivity` records it as the activity author —
254 // the recorded actor is the injected identity, not a wire field.
255 use crate::activity::{
256 canonical_verbs, ActivityDraft, ActivityRecord, ActivityStatus, SubmitActivity,
257 TargetKind, VerbCode,
258 };
259 use crate::brand::ShellId;
260 use crate::entry::EntryId;
261 use bytes::Bytes;
262
263 let (iid, tick) = fixture_args();
264 let injected = EntityId::new(0xAC).unwrap();
265 let action = SubmitActivity {
266 schema_version: 1,
267 draft: ActivityDraft {
268 schema_version: 1,
269 shell_id: ShellId([0u8; 16]),
270 verb: VerbCode::canonical(canonical_verbs::LIKE),
271 target: TargetKind::Entry(EntryId::new(EntityId::new(2).unwrap())),
272 at_tick: tick,
273 status: ActivityStatus::Active,
274 extra_bytes: Bytes::new(),
275 },
276 idempotency_key: None,
277 };
278 let ops = kernel_compute_inner(&action, iid, tick, Some(injected));
279 // SpawnEntity + SetComponent(ActivityRecord).
280 assert_eq!(ops.len(), 2, "submit emits spawn + set");
281 let recorded = ops.iter().find_map(|op| match op {
282 Op::SetComponent {
283 type_code, bytes, ..
284 } if *type_code == TypeCode(ActivityRecord::TYPE_CODE) => {
285 postcard::from_bytes::<ActivityRecord>(bytes).ok()
286 }
287 _ => None,
288 });
289 let recorded = recorded.expect("ActivityRecord SetComponent present");
290 assert_eq!(
291 recorded.actor.get(),
292 injected,
293 "recorded activity author must equal the injected kernel actor",
294 );
295 }
296
297 #[test]
298 fn user_scoped_compute_without_injected_actor_is_suppressed() {
299 // A+ proof: a user-scoped action with NO injected actor (kernel
300 // submitted with actor=None) rejects in compute, which the bridge
301 // collapses to an empty Op vec — it never reaches the WAL.
302 use crate::activity::{
303 canonical_verbs, ActivityDraft, ActivityStatus, SubmitActivity, TargetKind, VerbCode,
304 };
305 use crate::brand::ShellId;
306 use crate::entry::EntryId;
307 use bytes::Bytes;
308
309 let (iid, tick) = fixture_args();
310 let action = SubmitActivity {
311 schema_version: 1,
312 draft: ActivityDraft {
313 schema_version: 1,
314 shell_id: ShellId([0u8; 16]),
315 verb: VerbCode::canonical(canonical_verbs::LIKE),
316 target: TargetKind::Entry(EntryId::new(EntityId::new(2).unwrap())),
317 at_tick: tick,
318 status: ActivityStatus::Active,
319 extra_bytes: Bytes::new(),
320 },
321 idempotency_key: None,
322 };
323 let ops = kernel_compute_inner(&action, iid, tick, None);
324 assert!(
325 ops.is_empty(),
326 "user-scoped compute without an injected actor must produce no Ops",
327 );
328 }
329
330 #[test]
331 fn determinism_same_input_same_ops() {
332 // Bridge is a pure function: same `(action, instance_id, tick)`
333 // → byte-identical drained `Vec<Op>`. This is the consumer-side
334 // proof of A1 D1-Total replay determinism through the bridge.
335 // `arkhe_kernel::state::Op` does not implement `PartialEq`, so
336 // equality is asserted on the postcard-encoded form (which is
337 // what the kernel hashes into the WAL chain anyway).
338 let (iid, tick) = fixture_args();
339 let action = GdprEraseUser {
340 schema_version: 1,
341 user: UserId::new(EntityId::new(101).unwrap()),
342 };
343 let a = kernel_compute_inner(&action, iid, tick, None);
344 let b = kernel_compute_inner(&action, iid, tick, None);
345 assert_eq!(a.len(), b.len(), "Op count must match");
346 for (op_a, op_b) in a.iter().zip(b.iter()) {
347 let bytes_a = postcard::to_allocvec(op_a).expect("encode Op a");
348 let bytes_b = postcard::to_allocvec(op_b).expect("encode Op b");
349 assert_eq!(
350 bytes_a, bytes_b,
351 "bridge output must be byte-identical across runs",
352 );
353 }
354 }
355}