reaktor-capability
The missing runtime plane that turns raw platform facts, permissions, network quality, power state, renderer constraints, and measured app behavior into normalized capability profiles and strategy decisions. Product code should ask for a capability tier, not branch on Android, iOS, browser, Worker, JVM, model strings, or one-off adapter checks.
Verdict
Build reaktor-capability as a small KMP runtime module, not as another platform adapter collection and not as an expansion of reaktor-core. It should own normalized environment snapshots, capability profiles, policy evaluation, and strategy selection. Existing adapters keep doing privileged work; capability selects which adapter behavior is currently allowed and worth using.
Keep the API small
Expose profiles, requirements, strategies, and decisions. Do not create giant one-shot APIs like uploadSmartly() or renderBestUi().
Normalize for privacy
Bucket model strings, hardware facts, user-agent values, and network hints into coarse classes. Raw identifiers must not become business logic switches.
Make degradation visible
Every fallback should be inspectable in reaktorDesktop, traceable in telemetry, replayable in shadow, and testable with synthetic snapshots.
Source scan
The scan covered the spec chapter in /tmp/reaktor-spec.md, the docs in bestbuds/targets/reaktorWeb/public/docs, the Reaktor core and graph modules, platform adapters, performance/work/cloud modules, and the current reaktorDesktop workbench surfaces.
| Source | Relevant finding | Design implication |
|---|---|---|
/tmp/reaktor-spec.md chapter 20 | Defines the target vocabulary: EnvironmentSnapshot, normalized profiles, collectors, measured telemetry, tiers, and strategy policies. | This doc treats the spec as the intended direction and turns it into an implementation plan. |
reaktor-core | Capability is a minimal AutoCloseable lifecycle primitive. Feature is a process-level slot registry. Adapter bridges platform controllers through weak references. | Do not overload existing graph/runtime capabilities. Add a new runtime module and install it through a Feature.Capability slot. |
reaktor-graph and reaktor-graph-port | Graph composes lifecycle, dependency, concurrency, navigation, and port capabilities. Ports already make dependencies explicit and inspectable. | Capability requirements should attach to graph nodes, ports, and facets, then be interpreted by visitors and the workbench. |
| Platform adapters | PermissionAdapter, CameraAdapter, LocationAdapter, TaskManager, PubSubAdapter, Cloudflare bindings, and telemetry adapters already model platform-specific power. | Capability should select strategy and tier around these adapters, not replace them. |
| Existing docs | Graph Kernel calls capability/environment a canonical runtime plane. Notifications explicitly blocks on capability tiers. React x Compose needs signed, capability-scoped dynamic UI bundles. | The first milestone should unblock notifications and make capability visible to graph tooling. |
reaktor-tactile | The module exists but is currently a placeholder. | Tactile should consume capability decisions for haptics, shaders, animation intensity, reduced motion, and renderer strategy. |
reaktor-performance | Performance models already cover app vitals, web vitals, server vitals, flamegraph frames, tool runs, and budget enforcement. | Measured signals should feed capability decisions, and capability decisions should be included in perf reports. |
| reaktorDesktop workbench | The workbench has EnvironmentState, graph catalogs, graph overlays, command queue state, and an inspector, but no first-class capability plane. | Add a visual capability plane that overlays requirements, tiers, fallbacks, collector health, and strategy decision traces on the existing graph. |
Concept boundaries
The word capability already appears in the codebase. The new module needs strict language so it does not become a grab bag.
| Concept | Question answered | Examples | Owner |
|---|---|---|---|
| Feature slot | Which process-level service or adapter is installed? | Feature.Auth, Feature.Database, Feature.Permission, Feature.Work. | reaktor-core |
| Runtime mixin capability | Which behavior does this graph/node object compose? | Lifecycle, concurrency, DI, navigation, port capability. | reaktor-core, reaktor-graph, reaktor-graph-port |
| Permission | Has the OS, browser, platform, or user granted a privileged action? | Camera, location, gallery, notifications, storage. | Adapters and collectors |
| Environment | What coarse execution conditions exist right now? | Performance class, network quality, power mode, thermal state, renderer, foreground/background. | reaktor-capability |
| Capability profile | Can this runtime perform this operation, and at what quality tier? | Push notification full, local notification basic, camera still fallback, socket unavailable. | reaktor-capability |
| Strategy | Which implementation should run under the current snapshot and policy? | Remote push vs local schedule, WebSocket vs polling, multipart upload vs queued outbox. | reaktor-capability plus owning module |
Public model
The public API should be expressive enough to compose strategies, but small enough that feature modules remain readable. The main flow is request -> profile -> decision.
package dev.shibasis.reaktor.capability
import dev.shibasis.reaktor.core.framework.CreateSlot
import dev.shibasis.reaktor.core.framework.Feature
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.serialization.Serializable
@Serializable
value class CapabilityId(val value: String)
@Serializable
enum class CapabilityTier {
Unavailable,
Fallback,
Basic,
Standard,
Full,
}
@Serializable
data class CapabilityRequest(
val id: CapabilityId,
val purpose: String,
val requiredTier: CapabilityTier = CapabilityTier.Fallback,
val preferredTier: CapabilityTier = CapabilityTier.Full,
val attributes: Map<String, String> = emptyMap(),
)
@Serializable
data class CapabilityProfile(
val id: CapabilityId,
val tier: CapabilityTier,
val available: Boolean,
val reason: DegradationReason? = null,
val requiredPermissions: List<PermissionRef> = emptyList(),
val strategies: List<CapabilityStrategyDescriptor> = emptyList(),
val observedAtEpochMs: Long,
)
@Serializable
data class CapabilityDecision(
val request: CapabilityRequest,
val profile: CapabilityProfile,
val selectedStrategyId: String?,
val tier: CapabilityTier,
val trace: List<DecisionTraceEntry> = emptyList(),
)
interface CapabilityRuntime : AutoCloseable {
val environment: StateFlow<EnvironmentSnapshot>
val profiles: StateFlow<Map<CapabilityId, CapabilityProfile>>
val events: Flow<CapabilityRuntimeEvent>
suspend fun refresh(): EnvironmentSnapshot
fun profile(id: CapabilityId): StateFlow<CapabilityProfile>
suspend fun resolve(request: CapabilityRequest): CapabilityDecision
}
var Feature.Capability by CreateSlot<CapabilityRuntime>()
Environment snapshots
Public snapshots must expose normalized buckets only. Raw model strings, exact CPU names, precise device identifiers, user-agent values, and vendor-specific details are not product logic inputs.
@Serializable
data class EnvironmentSnapshot(
val device: DeviceProfile,
val execution: ExecutionProfile,
val network: NetworkProfile,
val renderer: RendererProfile,
val storage: StorageProfile,
val permissions: PermissionProfile,
val observed: ObservedQualityProfile,
val version: Long,
val createdAtEpochMs: Long,
)
@Serializable
data class DeviceProfile(
val platform: PlatformFamily,
val formFactor: FormFactor,
val performanceClass: PerformanceClass = PerformanceClass.Unknown,
val memoryClass: MemoryClass = MemoryClass.Unknown,
val pointerClass: PointerClass = PointerClass.Unknown,
)
@Serializable
data class ExecutionProfile(
val appState: AppState,
val powerMode: PowerMode = PowerMode.Normal,
val thermalClass: ThermalClass = ThermalClass.Normal,
)
@Serializable
data class NetworkProfile(
val connectivity: ConnectivityState,
val transports: Set<NetworkTransport> = emptySet(),
val cost: NetworkCost = NetworkCost.Unknown,
val quality: NetworkQuality = NetworkQuality.Unknown,
val rttMsHint: Double? = null,
val downlinkMbpsHint: Double? = null,
)
Stable public buckets
PerformanceClass, MemoryClass, NetworkQuality, NetworkCost, PowerMode, ThermalClass, FormFactor, RendererClass, PermissionState.
Private raw facts
Platform APIs, browser hints, user-agent values, hardware metrics, OS permission payloads, and cloud binding metadata. Convert them to buckets, then drop or redact them.
Runtime architecture
The runtime has three layers: collectors gather raw signals, the normalizer converts them into one EnvironmentSnapshot, and the strategy engine resolves module-defined capability strategies.
Platform collectors Common runtime Module strategies
------------------- -------------- -----------------
DeviceCollector \
PermissionCollector \
NetworkCollector +-> EnvironmentNormalizer -> EnvironmentSnapshot
PowerThermalCollector /
RendererCollector /
ObservedTelemetryCollector
|
v
CapabilityRegistry
|
v
CapabilityRequest -> StrategySelector -> CapabilityDecision -> module executes selected behavior
Collector contracts
interface CapabilitySignalCollector<Signal : Any> : AutoCloseable {
val id: String
val updates: Flow<Signal>
suspend fun collect(): Signal
}
interface EnvironmentNormalizer {
fun normalize(
previous: EnvironmentSnapshot?,
signals: CapabilitySignalSet,
observed: ObservedQualityProfile,
): EnvironmentSnapshot
}
interface CapabilityRegistry {
fun registerProfileProvider(provider: CapabilityProfileProvider)
fun registerStrategy(strategy: CapabilityStrategy<*, *>)
suspend fun resolve(request: CapabilityRequest, snapshot: EnvironmentSnapshot): CapabilityDecision
}
| Signal class | Examples | Decision timescale |
|---|---|---|
| Static or slow-changing | Platform family, form factor, approximate performance class, renderer support, installed cloud/runtime bindings. | Route start, graph attach, default UI density, default media strategy. |
| Dynamic runtime | Permission grants, foreground/background, connectivity, network cost, power saver, thermal state, storage pressure. | Ongoing adaptation: reduce animation, queue work, switch socket mode, downgrade media quality. |
| Measured app quality | Backend RTT, failure rate, dropped frames, long tasks, queue backlog, last successful upload, provider error class. | Real quality estimation when platform hints are absent, stale, or wrong. |
Strategy selection
Strategies are supplied by the module that owns the operation. The capability runtime evaluates requirements against the current snapshot and returns a decision, including why stronger strategies were rejected.
interface CapabilityStrategy<In : Any, Out : Any> {
val id: String
val capability: CapabilityId
val tier: CapabilityTier
val policy: CapabilityStrategyPolicy
suspend fun execute(input: In): Out
}
@Serializable
data class CapabilityStrategyPolicy(
val minPerformanceClass: PerformanceClass = PerformanceClass.Unknown,
val maxThermalClass: ThermalClass = ThermalClass.Critical,
val minNetworkQuality: NetworkQuality = NetworkQuality.Offline,
val allowMetered: Boolean = true,
val allowExpensive: Boolean = true,
val allowLowPower: Boolean = true,
val requiresForeground: Boolean = false,
val requiredPermissions: List<PermissionRef> = emptyList(),
)
suspend fun sendCampaignReminder(input: CampaignReminder) {
val decision = Feature.Capability.resolve(
CapabilityRequest(
id = CapabilityIds.PushNotification,
purpose = "campaign-reminder",
requiredTier = CapabilityTier.Fallback,
)
)
when (decision.tier) {
CapabilityTier.Full,
CapabilityTier.Standard -> remotePush.send(input)
CapabilityTier.Basic -> localNotification.schedule(input)
CapabilityTier.Fallback -> inboxBadge.mark(input.userId)
CapabilityTier.Unavailable -> Unit
}
}
Representative degradation paths
| Capability | Full or standard | Basic | Fallback | Unavailable |
|---|---|---|---|---|
| Push notification | Remote provider push | Local notification schedule | Inbox badge or in-app banner | Suppress and log decision |
| Media upload | Multipart direct upload to R2 | Sequential signed upload | Persist to outbox and retry | Disable send action with reason |
| Camera | Live preview and capture | Still capture | Gallery/file picker | Manual attachment unavailable state |
| Location | Precise live location | Coarse location | Manual city or region | Location-dependent feature hidden |
| Realtime | WebSocket or Durable Object room | Long polling | Queued sends and manual refresh | Read-only offline state |
| Tactile rendering | Shaders, springs, advanced haptics | Simple animation and basic haptics | Static transitions | No tactile effects |
Module integration
The capability runtime should land once and then be adopted by modules where fallback logic already leaks into product code.
| Module | Role in capability | First integration |
|---|---|---|
reaktor-core | Provides Feature, CreateSlot, Adapter, and lightweight lifecycle Capability. | Add Feature.Capability extension in the new module, not core. |
reaktor-notification | Owns push/local notification profiles and strategies. | Implement PushNotification and LocalNotification tiers to unblock notification design phase 1. |
reaktor-media and reaktor-location | Expose camera/gallery/location availability from adapters and permissions. | Move start failures into profiles: permission failure, missing controller, no hardware, degraded strategy. |
reaktor-work | Consumes network, power, foreground, and storage profiles for scheduling constraints. | Map work constraints to capability policies and expose worker eligibility decisions. |
reaktor-performance | Feeds measured quality and receives decision samples. | Add capability decision metrics, collector budgets, and degradation counts to reports. |
reaktor-telemetry | Observes capability events as spans and attributes. | Span names: capability.refresh, capability.resolve, capability.strategy.reject. |
reaktor-cloudflare and reaktor-google | Report runtime/binding availability and backend quality signals. | Profiles for Worker runtime, Durable Object state, R2 object storage, Pub/Sub, Hyperdrive, and service bindings. |
reaktor-tactile | Consumes renderer, haptic, accessibility, power, thermal, and frame-budget signals. | Define TactileRuntimePolicy from EnvironmentSnapshot and capability decisions. |
reaktor-graph | Declares and visualizes requirements on graph nodes/ports/facets. | Add capability requirement facets and visitor passes after v0 runtime is stable. |
Visual plane in reaktorDesktop
The workbench already has the right shell: environment state, graph catalog, graph pane overlays, side inspector, bottom drawer, command queue, performance panes, and source-opening workflows. The new work is to add a capability plane that projects capability requirements and live decisions onto the graph.
What the visual plane should show
Graph overlay
- Color nodes by strongest required capability tier.
- Badge nodes with active degradation count.
- Draw capability requirement edges without creating fake runtime edges.
- Overlay provider health for adapters, Worker bindings, Pub/Sub, R2, and local device services.
Inspector panel
- Selected node requirements.
- Current profile and selected strategy.
- Rejected strategies with reasons.
- Permission state and next user action.
- Last measured quality and last successful execution.
Environment controls
- Live, simulated, and shadow snapshot sources.
- Device target, network quality, power mode, thermal class, permission matrix.
- One-click scenario presets: offline commute, low-end Android, hot device, metered network, Worker binding missing.
Decision timeline
- Capability refresh events.
- Strategy upgrades and downgrades.
- Fallback execution counts.
- Links to traces, performance samples, source, tests, and shadow replay capsules.
Workbench data additions
@Serializable
data class WorkbenchCapabilityPlane(
val snapshot: EnvironmentSnapshot,
val summaries: List<WorkbenchCapabilitySummary>,
val selectedDecision: CapabilityDecision? = null,
val collectorHealth: List<CollectorHealth> = emptyList(),
)
@Serializable
data class WorkbenchCapabilitySummary(
val capabilityId: String,
val tier: CapabilityTier,
val requiredByGraphNodeIds: List<String>,
val selectedStrategyId: String?,
val degradationReason: String?,
)
@Serializable
data class WorkbenchCapabilityOverlay(
val graphNodeId: String,
val requiredCapabilities: List<String>,
val activeDegradations: Int,
val worstTier: CapabilityTier,
)
Implementation should extend the existing EnvironmentState instead of replacing it. The current fields such as project, environment, device target, locale, theme mode, and network mode become UI controls that synthesize an EnvironmentSnapshot for simulation and shadow runs. The existing WorkbenchGraphCatalog can add capability overlay metadata without changing graph rendering internals.
Build phases
P0 · Vocabulary and golden snapshots
Create the common model package, normalized enums, fixture snapshots, privacy rules, and sample decision traces. Include Android, iOS, web, Worker, JVM server, and desktop examples.
P1 · Runtime module v0
Add :reaktor-capability to Gradle. Implement CapabilityRuntime, no-op/default collectors, Feature.Capability, profile registry, strategy registry, and deterministic selector tests.
P2 · First collectors
Land platform, permission, network, execution, renderer, storage, and measured-quality collectors. Keep raw signal APIs internal and publish only normalized snapshots.
P3 · Notifications and Tactile adoption
Implement push/local notification strategies and the first TactileRuntimePolicy. This proves strategy selection across product-visible features and UI adaptation.
P4 · Media, location, work, and cloud profiles
Move existing adapter failure states into profiles. Add R2, Worker, Durable Object, Pub/Sub, and server/runtime profiles where they materially affect strategy.
P5 · Graph requirements and workbench plane
Add capability requirement facets, visitor output, workbench overlays, inspector panel, synthetic snapshot controls, and command-queue edits for policy/scenario changes.
P6 · Telemetry, shadow, and perf loop
Emit OTel spans, include capability decisions in perf reports, replay shadow traffic against synthetic snapshots, and fail CI on unsupported fallback regressions or hot-path budget violations.
Testing and performance
This module is a decision engine, so tests need to focus on determinism, privacy, and hot-path cost.
| Test type | What it proves | Examples |
|---|---|---|
| Golden snapshot tests | Raw signals normalize consistently across targets. | Low-end Android on metered LTE, iOS low power mode, browser missing network hints, Worker with R2 but no Hyperdrive. |
| Strategy selector tests | Fallback and upgrade behavior is deterministic and explainable. | Remote push rejected for permission denied, upload queued for offline, shaders disabled for hot thermal state. |
| Privacy tests | Public snapshots do not expose raw fingerprinting material. | No model strings, raw user agents, exact CPU names, vendor IDs, or unique hardware scores. |
| Collector contract tests | Collectors are async, bounded, and safe when platform APIs are missing. | Browser lacks navigator.connection, Android permission adapter missing, Cloudflare binding absent. |
| Workbench visual tests | The capability plane matches decisions and no UI labels overlap. | Select degraded node, inspect rejected strategies, switch to offline preset, verify overlay updates. |
| Performance harnesses | Decision resolution is cheap and collectors do not run on hot recomposition paths. | Benchmark resolve(), profile refresh fan-out, enforce collector timeout budgets. |
Runtime constraints
resolve()should be pure against the latest snapshot and registry state, with no platform probing in the hot path.- Collectors emit deltas into a conflated flow; the normalizer produces versioned immutable snapshots.
- Decision caches should key by request identity, snapshot version, and policy version.
- Expensive probes must be explicit, asynchronous, budgeted, and observable.
- Telemetry should record normalized tiers and reasons, not raw device facts.
Open decisions
Graph dependency boundary
Keep v0 graph-free, then add graph requirement facets through reaktor-graph or a small integration module. Avoid a hard graph dependency in the core capability runtime.
Policy source of truth
Decide whether product policy lives in code, serialized graph facets, server-configured manifests, or all three with clear precedence.
Permission prompts
Capability should describe required permissions and next actions, but the prompt UX should remain owned by product/platform modules.
Server capability shape
Client capability is mostly device/environment. Server and Worker capability also includes bindings, regional reachability, queue health, and provider quota.
Decision history retention
Workbench and telemetry need enough history for debugging, but retention must avoid turning normalized signals back into fingerprinting material.
Generated UI grants
React/Compose dynamic UI bundles should carry capability manifests. The runtime should reject undeclared native calls and surface missing grants in the workbench.
Related docs
- Core - Use before touching the platform boundary: registering a Feature slot, writing a platform Adapter, or branching on a device/network capability (which must be a normalized tier, never a raw model string).
- Graph Sources - Use when planning how existing non-Reaktor code enters the system graph: source scanning, runtime observation, ServiceNode wrapping, source navigation, and action gating by trust level.
- UI & Design System - Use before touching reaktor-ui tokens/components/theming, designing the tactile runtime (interaction/physics/field/skins/host-tree/reconciler), or planning adaptive layouts and AI/server-driven UI.
- Perf and Observability - Use before optimizing or adding instrumentation.
- Server - Use before adding any production backend primitive (resilience, messaging, data patterns) or designing the Spring/Workers server runtime.