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//! ## Caller preconditions
47//!
48//! The bridge is currently scoped to a narrow forge-action shape; the
49//! preconditions below are not enforced at compile time but are
50//! documented contract requirements for any forge action driven
51//! through `RuntimeService`:
52//!
53//! - **Determinism band must be `1` (Core).** Kernel-side `ActionDeriv`
54//! does not propagate forge `BAND` / `IDEMPOTENT` metadata, so a
55//! `BAND = 2` (Projection) or `BAND = 3` (Protocol) action would
56//! dispatch through the same kernel path as Core, breaking
57//! forge-side band-specific dispatch invariants. A future release
58//! wires band-aware kernel routing.
59//!
60//! - **`IDEMPOTENT` must be `false`.** Idempotent forge actions
61//! require the kernel-side
62//! [`IdempotencyIndex`](crate::context::IdempotencyIndex) integration
63//! (production fix: PG-UNIQUE-INDEX) before they can
64//! flow through this bridge safely.
65//!
66//! - **`compute()` body must not branch on
67//! [`ActionContext::principal`] / [`ActionContext::caps`] / the
68//! `world_seed`.** The bridge pins these to constants (limitation
69//! 2 above + the zero `world_seed`); branching on them would force
70//! a single replay path regardless of kernel-side caller intent,
71//! masking principal-aware behaviour. A future release exposes the
72//! kernel principal / caps through the bridge.
73
74use arkhe_kernel::abi::{CapabilityMask, InstanceId, Principal, Tick};
75use arkhe_kernel::state::{ActionContext as KernelActionContext, Op};
76
77use crate::action::ActionCompute;
78use crate::context::ActionContext;
79
80/// Bridge entry point invoked by the kernel-side `ActionCompute::compute`
81/// impl emitted by `#[derive(ArkheAction)]`.
82///
83/// See the [module-level docs](self) for the known limitations
84/// (`world_seed = 0`, principal pinning, error suppression).
85pub fn kernel_compute<A>(action: &A, kernel_ctx: &KernelActionContext<'_>) -> Vec<Op>
86where
87 A: ActionCompute,
88{
89 kernel_compute_inner(action, kernel_ctx.instance_id, kernel_ctx.now)
90}
91
92/// Testable inner helper — split out so the bridge can be unit-tested
93/// without reaching for a `KernelActionContext`, whose constructor is
94/// `pub(crate)` in the kernel and therefore unreachable from
95/// `arkhe-forge-core`.
96fn kernel_compute_inner<A>(action: &A, instance_id: InstanceId, now: Tick) -> Vec<Op>
97where
98 A: ActionCompute,
99{
100 let mut forge_ctx = ActionContext::new(
101 [0u8; 32],
102 instance_id,
103 now,
104 Principal::System,
105 CapabilityMask::SYSTEM,
106 );
107 if <A as ActionCompute>::compute(action, &mut forge_ctx).is_ok() {
108 forge_ctx.drain_ops()
109 } else {
110 Vec::new()
111 }
112}
113
114#[cfg(test)]
115#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
116mod tests {
117 use super::*;
118 use arkhe_kernel::abi::{EntityId, TypeCode};
119
120 use crate::event::ArkheEvent as _;
121 use crate::event::UserErasureScheduled;
122 use crate::user::{
123 AuthCredential, AuthKind, GdprEraseUser, GdprStatus, KdfKind, KdfParams, RegisterUser,
124 UserId, UserProfile,
125 };
126
127 fn fixture_args() -> (InstanceId, Tick) {
128 (InstanceId::new(7).unwrap(), Tick(99))
129 }
130
131 #[test]
132 fn ok_compute_returns_drained_ops() {
133 let (iid, tick) = fixture_args();
134 let action = GdprEraseUser {
135 schema_version: 1,
136 user: UserId::new(EntityId::new(42).unwrap()),
137 };
138 let ops = kernel_compute_inner(&action, iid, tick);
139 assert_eq!(ops.len(), 1, "GdprEraseUser emits one event Op");
140 match &ops[0] {
141 Op::EmitEvent {
142 actor,
143 event_type_code,
144 event_bytes: _,
145 } => {
146 assert!(actor.is_none());
147 assert_eq!(*event_type_code, TypeCode(UserErasureScheduled::TYPE_CODE));
148 }
149 other => panic!("expected EmitEvent, got {:?}", other),
150 }
151 }
152
153 #[test]
154 fn err_compute_returns_empty_vec() {
155 let (iid, tick) = fixture_args();
156 // RegisterUser with sub-baseline KDF params is rejected by the
157 // forge compute body (see `arkhe-forge-core/src/pipeline.rs`
158 // tests). The bridge must collapse that `Err` to an empty
159 // `Vec<Op>` — kernel sees a no-op submission.
160 let action = RegisterUser {
161 schema_version: 1,
162 profile: UserProfile {
163 schema_version: 1,
164 created_tick: Tick(0),
165 primary_auth_kind: AuthKind::Passkey,
166 gdpr_status: GdprStatus::Active,
167 },
168 credential: AuthCredential {
169 schema_version: 1,
170 kind: AuthKind::Passkey,
171 kdf: KdfKind::Argon2id,
172 salt: [0u8; 16],
173 credential_hash: [0u8; 32],
174 kdf_params: KdfParams {
175 m_cost: 1024,
176 t_cost: 1,
177 p_cost: 1,
178 },
179 expires_tick: None,
180 bound_tick: Tick(0),
181 },
182 };
183 let ops = kernel_compute_inner(&action, iid, tick);
184 assert!(
185 ops.is_empty(),
186 "weak-KDF RegisterUser must collapse to empty Op vec",
187 );
188 }
189
190 #[test]
191 fn determinism_same_input_same_ops() {
192 // Bridge is a pure function: same `(action, instance_id, tick)`
193 // → byte-identical drained `Vec<Op>`. This is the consumer-side
194 // proof of A1 D1-Total replay determinism through the bridge.
195 // `arkhe_kernel::state::Op` does not implement `PartialEq` in
196 // v0.13, so equality is asserted on the postcard-encoded form
197 // (which is what the kernel hashes into the WAL chain anyway).
198 let (iid, tick) = fixture_args();
199 let action = GdprEraseUser {
200 schema_version: 1,
201 user: UserId::new(EntityId::new(101).unwrap()),
202 };
203 let a = kernel_compute_inner(&action, iid, tick);
204 let b = kernel_compute_inner(&action, iid, tick);
205 assert_eq!(a.len(), b.len(), "Op count must match");
206 for (op_a, op_b) in a.iter().zip(b.iter()) {
207 let bytes_a = postcard::to_allocvec(op_a).expect("encode Op a");
208 let bytes_b = postcard::to_allocvec(op_b).expect("encode Op b");
209 assert_eq!(
210 bytes_a, bytes_b,
211 "bridge output must be byte-identical across runs",
212 );
213 }
214 }
215}