Reaktor Core — the foundation layer

reaktor-core is the layer everything else stands on: the Feature slot registry that lets a graph node ask for Feature.Database without ever branching on if (isAndroid), the Capability AutoCloseable resource marker, the Adapter weak-reference bridge to platform controllers, and the cross-platform primitives (time, coroutine dispatchers, logging, atomics, serialization). It exists, in the words of the spec, "to eliminate every 'is this Android?' branch." This doc is the code-grounded foundation half of the merged core + capability entry; the capability runtime that normalizes device/network facts into tiers is a designed plane that installs on core via a Feature slot — covered in depth in reaktor-capability, which this doc bridges to rather than duplicates.

The honest framing up front: core is real and the most-used module in the framework — but lean; the capability runtime is a plan, not a module. Feature, Capability, Adapter, and the primitives all ship and are used across bestbuds and every reaktor module. What is not there: any auto-bootstrap of platform adapters (registration is manual), a finished iOS controller path, a thread-safe way to reconfigure the global JSON, and — entirely — reaktor-capability as a Gradle module (no EnvironmentSnapshot, no CapabilityTier, no normalized enums exist in code; the spec's "fully typed but design-stage" is aspirational). And one piece of real technical debt this doc names head-on: the word "capability" is overloaded four ways, none of which is the object-capability security model.

  • Feature the slot registry — var Feature.X by CreateSlot<T>(); register at the boundary, read anywhere without a platform branch
  • Capability a minimal AutoCloseable resource marker (AtomicCapability = idempotent CAS close) — not device tiers, not a security token
  • Adapter<Controller> a WeakRef bridge so common code drives a platform controller without owning or leaking it
  • Primitives time · dispatchers · logging · atomics · serialization — the batteries that erase platform branches
  • The capability runtime sits on core raw platform facts → normalized tiers (never raw scores) → a strategy decision; installed via Feature.Capability (reaktor-capability)
  • The honest law expose tiers, not raw fingerprints — the same entropy-reduction the web platform now enforces (UA reduction, deviceMemory power-of-two rounding, bucketed effectiveType)

1. Vision — erase the platform branch

Every cross-platform framework dies the same death: if (Platform.isAndroid) … else if (isIOS) … else if (isBrowser) … metastasizing through business logic until the "shared" code is a switch statement with shared imports. reaktor-core exists to make that branch impossible to write. A graph node asks for Feature.Database or Feature.Camera; whichever platform booted registered the right implementation into that slot; the node never learns which platform it is on. Platform-specific power lives behind an Adapter that common code drives through a weak handle; platform differences in time, threading, atomics, and serialization are absorbed by expect/actual primitives.

This is L0 — the foundation layer, below the graph runtime, that every other module imports. It is deliberately small: a registry, a resource marker, an adapter, and a handful of primitives. The discipline is "add nothing here that a higher layer could own." Concretely: core does not do dependency injection (Koin does, bridged through a slot), does not know about the graph, and does not grow the capability runtime inside itself — that installs on core, through a slot, as its own module.


2. Implementation state

Written against the live code in reaktor-core (and consumers in bestbuds + the other reaktor modules). Paths are module-relative.

CapabilityWhereStatus
Feature slot registry — object Feature, ConcurrentHashMap<Int,Any> + AtomicInt, createId/storeDependency/fetchDependency/close; var Feature.X by CreateSlot<T>()reaktor-core/.../framework/Feature.ktImplemented (~21 live slots)
Capability : AutoCloseable marker + AtomicCapability (atomic(false) CAS close-guard + doClose())reaktor-core/.../capabilities/Capability.ktImplemented
Adapter<Controller> — WeakRef<Controller>, null-safe invoke/suspended/invokeResult/suspendedResult, handle(event)reaktor-core/.../framework/Adapter.ktImplemented
WeakRef / AtomicInt / ConcurrentHashMap (456-line lock-free) — expect/actual per target (JS WeakRef is a no-op)reaktor-core/.../framework/Expectations.kt, .../structs/Implemented
Platform code in src/{android,ios,jvm,js}Main; Android registers via BaseActivity.connect(adapters)reaktor-core/src/*MainPartial (manual registration; no auto-bootstrap; iOS BaseController is a stub)
Primitives: epochTime() (kotlinx-datetime), Dispatch.{Main,IO,Default} (+ Dispatchers.Async expect/actual), Kermit logging, atomicfureaktor-core/.../utils, .../framework/Dispatch.ktImplemented
JSON — kotlinx.serialization + JsonBuilder.defaults() (encodeDefaults, ignoreUnknownKeys, classDiscriminator="type")reaktor-core/.../framework/Json.ktImplemented (global var json not safe to reconfigure concurrently)
Dependency injectionKoin, via the Feature.Dependency slot → reaktor-graph DI adapterImplemented (separate concern; Feature ≠ DI)
reaktor-capability module + EnvironmentSnapshot/CapabilityTier/PerformanceClass/NetworkCost/ThermalClass/CapabilityStrategy—Absent (not in settings.gradle.kts; zero types in code)
Privacy normalization layer (no-raw-strings enforcement)—Absent (raw getShibasisUserAgent() exists on web)
WindowWidthClass-style adaptive layout in reaktor-ui—Absent

Takeaway: Feature is the framework's spine — used by bestbuds and by reaktor-db/-media/-auth/-ui/-io/-telemetry/-work for ~21 slots. Capability (the marker) is used lightly (as the base of the graph's behavioral mixins). The capability runtime — the thing that would turn raw device facts into tiers — does not exist yet. Core is "Stable" in the sense the spec means (the API is small, settled, and load-bearing), with honest rough edges on iOS/web and bootstrap.


3. The five concepts, and the four meanings of "capability"

The single most important thing this doc does is disambiguate. "Capability" is overloaded across the codebase, and a reviewer steeped in security will misread it. State the boundaries before anything else.

ConceptQuestion it answersOwnerExamples
Feature slotWhich process-level service/adapter is installed?reaktor-coreFeature.Auth, Feature.Database, Feature.Camera, Feature.Work
Capability (resource)Is this a closable resource with a lifecycle?reaktor-corean AtomicCapability owning a socket / scope / handle
Runtime-mixin capabilityWhich behavior does this graph object compose?reaktor-graph, reaktor-graph-portLifecycleCapability, DependencyScopeCapability, PortCapability
PermissionHas the OS/user granted a privileged action?adapterscamera, location, notifications
Environment capability (tier)What can the device/network do right now, and at what quality?reaktor-capability (designed)PerformanceClass.Low, NetworkCost.Metered, ThermalClass.Serious
StrategyWhich implementation should run under the current tier + policy?reaktor-capability + owning moduleremote push vs local notify; multipart vs queued upload

The rest of this doc walks core's three primitives — Feature, Capability, Adapter — then the primitives, then how the capability runtime sits on top.


4. Feature — the slot registry

Reaktor primitive: object Feature, a process-level, thread-safe typed slot registry. A module declares a slot with a delegate; a platform/bootstrap fills it; any code reads it without knowing the platform.

// Declare a slot (in the owning module):
var Feature.Database by CreateSlot<ObjectDatabase>()
var Feature.Camera   by CreateSlot<CameraAdapter<*>>()

// Fill it at the boundary (platform bootstrap / app start):
Feature.Database = sqlDelightDatabase()       // the actual impl for this target
// Read it anywhere — no `if (isAndroid)`:
val db = Feature.Database

Under the hood Feature is a singleton over a lock-free ConcurrentHashMap<Int, Any> keyed by an AtomicInt-allocated slot id, with createId / storeDependency / fetchDependency<T> / close. ~21 slots ship today — Auth, Database, Sql, GraphQl, Camera, Gallery, Speech{Recognizer,Synthesizer}, GooglePubSub, Work, Theme, Location, Compression, File, Permission, Storage, Analytics, Crashlytics, Telemetry, Dependency — and bestbuds plus most modules read them. 80/20: reaktor owns the typed registry; platform bootstraps and modules populate the slots.


5. Capability — the resource marker

Reaktor primitive: interface Capability : AutoCloseable — a minimal marker for "a thing with a lifecycle you must close" — and AtomicCapability, which adds an idempotent close guard so a double-close() is a safe no-op.

interface Capability : AutoCloseable

abstract class AtomicCapability : Capability {
    private val closed = atomic(false)                 // kotlinx-atomicfu, multiplatform
    final override fun close() {
        if (closed.compareAndSet(expect = false, update = true)) doClose()
    }
    protected abstract fun doClose()                   // runs exactly once
}

This is exactly the canonical pattern the Kotlin stdlib prescribes — "close should be idempotent" — realized with an atomic CAS so it is correct under concurrency. The companion discipline is scope ownership: a coroutine-scoped resource can be cancelled before its cleanup runs, so the owner of the CoroutineScope owns teardown (cancel the Job → resources close); use { } guarantees close on exception. 80/20: reaktor owns the close-guard contract; the resource's actual cleanup is doClose(). (Reminder: this Capability is a closable resource, not a device tier and not an authority token — §3.)


6. Adapter — the WeakRef platform bridge

Reaktor primitive: Adapter<Controller> — common code drives a platform controller (an Android ComponentActivity, an iOS view controller, a browser global) through a weak reference, so the framework can use platform power without owning the platform object or leaking it.

open class Adapter<Controller>(controller: Controller) : Component {
    val ref: WeakRef<Controller> = WeakRef(controller)
    val controller: Controller? get() = ref.get()

    operator fun <R> invoke(fn: Controller.() -> R?): R?            // null-safe: no controller → null
    suspend fun  <R> suspended(fn: suspend Controller.() -> R?): R?
    // + invokeResult / suspendedResult returning Result<T>
    open fun handle(event: ControllerEvent) { /* lifecycle */ }
}

WeakRef is expect/actual per target (JVM/Android → java.lang.ref.WeakReference, iOS → kotlin.native.ref.WeakReference, JS → a no-op, since the browser GC model differs). Concrete adapters — PermissionAdapter, StorageAdapter, CameraAdapter, AuthAdapter — subclass it and do the privileged platform work; common code holds only the weak handle and calls through invoke/suspended. This is the well-grounded KMP shape: a platform-agnostic interface with the actual injected at the boundary (the Essenty/Decompose model), preferred over expect class (still Beta in Kotlin 2.x). 80/20: reaktor owns the adapter contract + WeakRef discipline; the platform supplies the controller.

Honest gaps: adapters register manually (e.g. Android BaseActivity.connect(listOf(permissionAdapter, …)) wires them into the lifecycle) — there is no auto-bootstrap, so a forgotten registration fails at use-time; and the iOS BaseController is currently a stub. Auto-bootstrap + fail-fast registration is the core hardening item (§11).


7. Primitives — the batteries

The handful of cross-platform utilities that erase the most common platform branches:

  • Time — epochTime(): Long over kotlinx-datetime Clock.System.
  • Dispatchers — Dispatch.{Main, IO, Default} wrappers, with Dispatchers.Async chosen per platform via expect/actual (Android/iOS → IO, JS → Default, JVM → IO).
  • Logging — a thin wrapper over Touchlab Kermit (info/debug/warn/verbose).
  • Atomics & structures — kotlinx-atomicfu for multiplatform CAS, plus a hand-written 456-line lock-free ConcurrentHashMap (open-addressing incremental resize, Herlihy & Shavit) — a deliberate primitive since the JS/Native worlds lack a shared one. (Comment in code: it trades ~2–5× the throughput of java.util.concurrent for portability.)
  • Serialization — kotlinx.serialization with JsonBuilder.defaults(): encodeDefaults = true, ignoreUnknownKeys = true, classDiscriminator = "type" (the discriminator the whole framework's sealed hierarchies rely on). Honest gap: the global var json is mutable and not safe to reconfigure concurrently — fine to read everywhere, risky to re-set at runtime.

These lean on the standard kotlinx ecosystem (atomicfu, datetime, and serialization), which is the right call — core wraps them with reaktor defaults rather than reinventing them.


8. The capability runtime sits on core

The foundation answers "which implementation runs here?" (via Feature + Adapter). The capability runtime extends the same idea to "at what quality tier, under the current conditions?" — and it installs on core through exactly one more slot:

// reaktor-capability (designed) installs itself as a Feature slot — it does NOT live in core.
var Feature.Capability by CreateSlot<CapabilityRuntime>()

interface CapabilityRuntime : AutoCloseable {                 // a Capability, fittingly
    val environment: StateFlow<EnvironmentSnapshot>
    suspend fun resolve(request: CapabilityRequest): CapabilityDecision
}

The runtime's job: collectors gather raw platform signals via the Adapter pattern → a normalizer folds them into one immutable EnvironmentSnapshot of normalized tiers (PerformanceClass, MemoryClass, NetworkQuality, NetworkCost, ThermalClass, PowerMode, FormFactor) → a StrategySelector picks an implementation by CapabilityTier and policy, with a traceable decision. Product code branches on CapabilityTier, never on a platform fact. This is the reaktor-capability plane in full — its collectors, normalizer, strategy engine, degradation paths, reaktorDesktop overlay, and build phases live in that doc; this section only establishes the seam (it installs via Feature.Capability, it is itself a Capability/AutoCloseable, it consumes Adapter-shaped collectors). Status: designed; the module does not exist yet.


9. The normalization & privacy law

Reaktor law: the public API exposes normalized tiers, never raw model strings, vendor IDs, user-agent platform strings, or raw hardware scores as business-logic switches. This is the load-bearing rule of the capability plane — and it is not a stylistic preference; it is the direction the platforms themselves now enforce:

  • The web froze raw fingerprints. User-Agent reduction (Chrome, now GA) collapses the OS version and device model to anchor constants (Android 10; K); User-Agent Client Hints make high-entropy values opt-in request-only. navigator.deviceMemory is rounded to the nearest power of two by spec to cap entropy; the Network Information API exposes a bucketed effectiveType (slow-2g|2g|3g|4g) and a saveData boolean, not the raw radio. W3C fingerprinting guidance prescribes exactly three moves: clamp precision, enumerate over granularity, boolean-reduce. That is reaktor's tier model.
  • Android computes the tier for you. Media Performance Class (Build.VERSION.MEDIA_PERFORMANCE_CLASS, via Jetpack androidx.core:core-performance / Play Services) is a platform-validated, OS-decoupled, forward-compatible integer tier — the exemplar reaktor's PerformanceClass should mirror (a tier that stays stable across OS upgrades, so strategy selection doesn't silently shift).
  • iOS hands you raw signals to normalize. There is no MPC equivalent — apps read ProcessInfo (thermalState: nominal/fair/serious/critical, isLowPowerModeEnabled, processorCount, physicalMemory) and are expected to react to thermal/power pressure. So the iOS actual must bucket those raw values into the same enums internally and never expose them — which is precisely where the privacy law earns its keep.

The companion stance is progressive enhancement, not graceful degradation: the default code path must be the one that runs on the lowest tier, so an unknown/unsupported capability (Android MPC 0, Network Info absent on Safari/Firefox, pre-API-30) resolves to the conservative floor, and richer tiers are opt-in grants the snapshot adds. 80/20: reaktor owns the tier enums + the floor-default contract; the platform computes the raw signals. Honest status: today this law is unenforced — a raw getShibasisUserAgent() exists on web and there is no normalization layer, because the capability module that would own it isn't built.


10. The reaktor-core module

A tiny foundation, kept tiny on purpose.

Adds: the Feature slot registry; the Capability/AtomicCapability resource markers; the Adapter<Controller> WeakRef bridge + base adapter subclasses; expect/actual primitives (WeakRef, AtomicInt, ConcurrentHashMap, Dispatch); and the standard-lib wrappers (Kermit logging, kotlinx-datetime time, kotlinx.serialization defaults). Platform code lives in src/{android,ios,jvm,js}Main — not as separate Gradle modules.

Consumes: only the kotlinx ecosystem (atomicfu, datetime, serialization, coroutines) + Kermit. Nothing reaktor-internal — it is the bottom of the stack.

Must not: become the DI container (Koin owns DI, bridged via Feature.Dependency); grow the capability runtime inside itself (it installs via a slot, as its own module); leak platform types or raw fingerprints into common APIs; or become a god-object of static helpers. Core is a registry, a marker, an adapter, and a few primitives — and stays that.


11. Phased plan

Core is largely done; the work is hardening it and building the capability runtime on top.

PhaseDeliverableNotes
C0 — Harden the registryFail-fast slot validation (missing slot errors at boot, not mid-call); document the "resolve at the boundary, constructor-inject" disciplineservice-locator critique (§4)
C1 — Bootstrap & iOSPer-platform auto-bootstrap so adapters register without manual connect; finish the iOS BaseController/Darwin adapterscloses the manual-registration gap (§6)
C2 — Primitive cleanupMake JSON config immutable/per-scope (not a global mutable var); audit expect/actual (prefer interface+injected actual over expect class, still Beta)§7
C3 — Capability vocabularyLand the normalized enums + EnvironmentSnapshot + golden fixtures + the privacy rule as the first slice of reaktor-capabilityper the capability doc P0
C4 — Capability runtime v0:reaktor-capability module, CapabilityRuntime, Feature.Capability, collectors, deterministic StrategySelectorper the capability doc P1–P2

C0–C2 harden what exists; C3–C4 are the capability runtime, sequenced and detailed in reaktor-capability — this doc does not duplicate that plan.


12. Open decisions

  • The "capability" naming collision. Three in-code meanings + the ocap word (§3). Options: rename core's Capability → Closeable/Resource; reserve "Capability" for device tiers; or keep names but enforce a documented disambiguation convention. A rename touches the graph mixins, so it is the user's call — flag, don't unilaterally rename.
  • Service-locator discipline for Feature. Make resolution boundary-only + fail-fast a lint/convention, or leave it as guidance? (§4).
  • expect class vs interface + injected actual. Standardize on the latter (Beta-safe, matches the adapter pattern) across core?
  • Where capability normalization lives. Per the capability doc: keep v0 graph-free and core-light (install via slot), confirmed.
  • The unknown → floor policy. Make "unsupported signal resolves to the conservative tier" a hard, tested contract on every capability axis (§9).

13. References

Verified June 2026. Core API claims are grounded in the live reaktor-core code; external claims against primary platform/standards docs.

reaktor-core (code)

  • Feature registry — reaktor-core/.../framework/Feature.kt; slot delegate CreateSlot<T>. Capability/AtomicCapability — .../capabilities/Capability.kt. Adapter<Controller> + WeakRef — .../framework/Adapter.kt, .../framework/Expectations.kt. ConcurrentHashMap — .../structs/. Android bootstrap — src/androidMain/.../BaseActivity.kt.

Privacy-preserving capability detection

  • User-Agent Client Hints — Chrome for Developers · UA reduction — Privacy Sandbox (opt-in hints; frozen UA).
  • Network Information API — MDN ("Limited availability — not Baseline"; no Firefox/Safari → treat as optional, feature-detected); effectiveType buckets + Save-Data.
  • Mitigating Browser Fingerprinting in Web Specifications — W3C TAG/PING (clamp / enumerate / boolean-reduce; the entropy-reduction toolkit).

Platform capability tiers

Patterns


See also

  • Capability Runtime - Use before branching behavior by platform, device, network, permission, or renderer state.
  • Service Layer - Use before adding service APIs, splitting reaktor-service out of reaktor-graph, wiring handlers to Spring/Workers, or making modules graph-projectable without hard graph dependencies.
  • FFI - Use before changing the native bridge, FlexBuffer-RPC protocol, Hermes runtime path, ABI surface, graph representation, or target/binding support.
  • FlexBuffer - Use before changing binary serialization or reaktor-flexbuffer hot paths.
  • Server - Use before adding any production backend primitive (resilience, messaging, data patterns) or designing the Spring/Workers server runtime.