21 KiB
ControlServices Subsystem Design
This document expands the ControlServices subsystem described in PHASE_1_SUBSYSTEM_BOUNDARIES_DESIGN.md. It defines the target role of ControlServices as the ingress boundary for non-render control sources and the normalization layer that turns external input into typed internal actions.
The intent here is to make ControlServices explicit enough that later phases can extract it from the current RuntimeServices / ControlServer / OscServer mix without inventing new boundaries ad hoc.
Purpose
ControlServices is the subsystem that accepts external control traffic and turns it into safe, typed, low-cost input for the rest of the app.
In the target architecture, ControlServices should:
- own ingress for OSC, HTTP/REST-style control routes, WebSocket session management, and file-watch/reload signals
- normalize transport-specific payloads into typed internal actions or events
- apply ingress-local buffering, coalescing, deduplication, and rate limiting where useful
- expose service timing and health observations to
HealthTelemetry - forward normalized actions into
RuntimeCoordinator
It should not:
- decide persistence policy
- mutate persisted state directly
- build render snapshots
- own render-local overlay state
- own device timing or playout policy
This subsystem is intentionally narrow in authority and broad in transport coverage.
Why This Subsystem Exists
Today the app already has a recognizable control-services slice, but it is spread across several classes:
RuntimeServiceshosts control server startup, OSC queues, deferred OSC commits, and file-watch polling:ControlServerowns HTTP, WebSocket upgrade, static asset serving, and direct callback-based route dispatch:OscServerowns UDP socket receive, OSC decode, and parameter callback dispatch:
The current shape works, but it mixes:
- transport handling
- action normalization
- direct callback dispatch
- coarse background polling
- transient queue ownership
- UI broadcast behavior
- partial runtime mutation coordination
That overlap is exactly what Phase 1 is trying to remove.
Design Goals
ControlServices should optimize for:
- low-latency ingress without forcing immediate whole-app work
- clear transport boundaries
- deterministic normalization of external input
- isolation of service-specific timing concerns
- easy replacement of polling flows with typed events
- no direct knowledge of render-local implementation details
- safe behavior under bursty traffic such as high-rate OSC
Subsystem Responsibilities
ControlServices owns the following concerns.
1. Transport Ingress
It accepts input from external control-facing sources such as:
- OSC/UDP parameter control
- HTTP API requests from the native control UI or external clients
- WebSocket connection lifecycle for state consumers
- file-watch triggers and manual reload requests
- future automation ingress such as MIDI, serial, or remote control bridges
The key rule is that transport-specific details stop here.
2. Action Normalization
Every ingress path should be converted into a typed internal action or event before it touches runtime policy.
Examples:
- OSC
/layer/paramtraffic becomesAutomationTargetReceived POST /api/layers/addbecomesLayerAddRequestedPOST /api/reloadbecomesShaderReloadRequested- file-watch changes become
RegistryChangedDetectedorReloadRequested
The rest of the app should not need to know whether an action came from UDP, HTTP, the embedded UI, or a background watcher.
3. Ingress-Local Buffering and Coalescing
ControlServices may maintain short-lived queues or coalesced maps when that is the correct place to absorb bursty input.
Examples:
- latest-value coalescing per OSC route
- pending reload edge detection
- bounded outbound state-broadcast requests
- short-lived delivery queues for already-classified follow-up work, as long as commit and persistence policy still belong to
RuntimeCoordinator
This state is ingress-local and must not become a substitute for committed runtime state.
4. WebSocket Session Management
The subsystem owns connection lifecycle for clients that observe runtime state, but it does not own the authoritative runtime model.
It is responsible for:
- accepting WebSocket upgrades
- tracking connected clients
- forwarding serialized state snapshots or health payloads produced elsewhere
- applying broadcast throttling or collapse policies when necessary
It is not responsible for deciding what the authoritative state is.
5. File-Watch and Reload Ingress
The subsystem should own the detection side of registry/file changes and reload requests.
It may:
- observe filesystem changes
- debounce bursts of related file events
- translate those changes into typed reload actions
It should not directly trigger render rebuilds or mutate shader/package state itself.
6. Service Health and Timing Reporting
ControlServices should emit operational signals into HealthTelemetry, including:
- OSC packet rate
- OSC decode failures
- queue depth / coalesced route count
- dropped or collapsed ingress events
- HTTP error counts
- WebSocket connection count
- reload request frequency
- file-watch failures
- service-thread startup/shutdown errors
Explicit Non-Responsibilities
The following must stay outside ControlServices in the target design.
Persistence Decisions
The subsystem may report that an input requested a state change, but it should not decide whether that change is persisted.
That belongs to RuntimeCoordinator, with RuntimeStore and the later persistence writer carrying out durable writes when policy requests them.
Render Snapshot Publication
ControlServices must not publish render-facing snapshots or poke render-local structures directly.
Render-Local Overlay Ownership
Live OSC automation overlays belong to the live-state/render preparation boundary (RuntimeLiveState today). Temporal state, shader feedback, output staging, and other render-only transient state belong to RenderEngine.
ControlServices may ingest and coalesce automation targets, but it should not own how those targets are composed, committed, persisted, or applied inside the render domain.
Hardware Timing or Playout Recovery
Device scheduling, queue headroom, and callback recovery belong to VideoBackend, not the control ingress path.
Ingress Boundary Model
The clean boundary for ControlServices is:
- external transport in
- typed action/event out
That implies three layers inside the subsystem.
Transport Adapters
These are protocol-facing components.
Examples:
OscIngressHttpControlIngressWebSocketSessionHostFileWatchIngress
Responsibilities:
- socket/file watcher lifecycle
- protocol decoding
- request framing
- transport-level validation
- low-level authentication or origin checks later if added
Normalization Layer
This layer translates decoded transport input into typed actions.
Responsibilities:
- route parsing
- payload type normalization
- parameter name/key resolution where that is purely syntactic
- conversion from transport-specific errors into typed ingress errors
This layer should not perform deep runtime mutation policy.
Service Coordination Shell
This shell owns:
- startup/shutdown ordering for ingress services
- shared ingress-local queues
- service-thread lifecycle
- handing normalized actions to
RuntimeCoordinator - handing outbound snapshot payloads to WebSocket clients
This shell is the spiritual successor to the hosting part of current RuntimeServices, but with a much narrower responsibility set.
Service Timing Concerns
ControlServices is the correct place to isolate transport-level timing concerns that should not leak into whole-app state policy.
OSC Timing
Current behavior already points in the right direction:
- OSC receive is on its own thread in
OscServer - latest values are coalesced by route in
RuntimeServices - updates are applied once per render tick rather than per packet
Relevant code:
Target rule:
- network receive and decode stay inside
ControlServices - coalescing policy stays inside
ControlServices - classification of the resulting action belongs to
RuntimeCoordinator - render-local application belongs to
RenderEngine
This keeps high-rate ingress cheap without giving the service layer authority over render behavior or committed-state policy.
HTTP / UI Timing
HTTP control requests are operator-facing and usually low-rate, but the UI can still generate bursts through slider drags or repeated parameter edits.
ControlServices should:
- normalize each request into a typed action
- allow collapse/throttle policies for purely observational outbound state pushes
- avoid synchronous full-state serialization on every ingress event where possible
It should not decide whether a request results in immediate, deferred, transient, or persisted mutation. That is a coordinator concern.
WebSocket Broadcast Timing
Outbound state streaming is control-plane behavior, not core runtime ownership.
Current code already distinguishes immediate and requested broadcasts:
Target rule:
ControlServicesmay own broadcast scheduling and collapse policy- the source state payload should come from snapshot/telemetry producers, not from service-owned mutable state
File-Watch Timing
Current file-watch and deferred OSC commit work run on a coarse poll loop:
This is one of the cleaner migration opportunities in the whole app.
Target rule:
- file-watch detection belongs in
ControlServices - coarse polling should eventually be replaced with either event-driven watching or a narrower, typed background loop
- detected changes should be debounced and surfaced as typed reload-related actions
Service Backpressure
ControlServices needs explicit backpressure rules for high-rate sources.
Recommended policies:
- coalesce latest-value automation by route
- bound per-service queues
- count and report dropped/coalesced events
- prefer collapsing observation work before collapsing operator mutations
- never let service queues become hidden durable state
Interfaces
These are suggested target-facing interfaces, not final class signatures.
Subsystem Shell
Possible top-level responsibilities:
Start(...)Stop()PublishStateSnapshot(...)PublishHealthSnapshot(...)DrainNormalizedActions(...)
The shell should feel like a host for ingress adapters plus a normalization/buffering boundary.
OSC Ingress
Possible responsibilities:
StartOscIngress(...)StopOscIngress()ConfigureOscBinding(...)EnqueueDecodedOscMessage(...)DrainCoalescedAutomationTargets(...)
HTTP / Web Control Ingress
Possible responsibilities:
StartHttpIngress(...)StopHttpIngress()HandleHttpRequest(...)HandleWebSocketUpgrade(...)QueueStateBroadcastRequest()
File-Watch Ingress
Possible responsibilities:
StartFileWatchIngress(...)StopFileWatchIngress()PollOrConsumeFileEvents(...)DrainReloadSignals(...)
Normalized Action Types
These should likely become shared event/action definitions in Phase 2, but ControlServices should be designed around them now.
Examples:
LayerAddRequestedLayerRemovedRequestedLayerReorderedRequestedLayerBypassSetRequestedLayerShaderSetRequestedParameterSetRequestedLayerResetRequestedStackPresetSaveRequestedStackPresetLoadRequestedShaderReloadRequestedScreenshotRequestedAutomationTargetReceivedRegistryChangeDetected
Data Ownership Inside The Subsystem
ControlServices is allowed to own ingress-local ephemeral state.
Examples:
- connected WebSocket client list
- pending broadcast flag
- coalesced OSC route map
- outstanding decoded-but-undrained action queue
- file-watch debounce state
- transport error counters before publication to telemetry
It should not own:
- authoritative layer stack state
- committed parameter values
- render snapshots
- playout queue state
- shader feedback or render overlays
The rule is simple:
- if the state exists only to absorb or forward external input, it can live here
- if the state defines how the app should behave over time, it belongs elsewhere
Outbound Boundaries
ControlServices talks outward in only a few approved directions.
To RuntimeCoordinator
Primary outbound path.
It sends:
- normalized mutation requests
- automation targets
- reload requests
- stack preset requests
It does not send:
- transport-specific objects such as raw sockets or OSC packet structures
- render-facing state objects
To HealthTelemetry
Observation-only relationship.
It sends:
- counters
- warnings
- timing samples
- service health transitions
It should not use HealthTelemetry as a hidden control path.
From Snapshot / Telemetry Producers To Web Clients
ControlServices may deliver serialized outbound payloads to WebSocket clients, but the authoritative payload contents should be produced by the owning subsystems.
That means a later design may look like:
RuntimeSnapshotProviderprovides render-facing snapshot payloads or a runtime-state projection derived from those published snapshotsRuntimeCoordinatoror a later runtime-read-model helper provides control-plane runtime summaries when the UI needs more than raw render stateHealthTelemetryprovides health payloadsControlServicesdelivers them to connected observers
Current Code Mapping
This section maps the current implementation onto the target subsystem.
Current RuntimeServices
Should split into:
ControlServicesshell- temporary compatibility adapter into
RuntimeCoordinator - removal of any direct runtime-state mutation responsibilities over time
Likely keep under ControlServices:
- service startup/shutdown
- OSC update coalescing
- Web control hosting shell
- file-watch ingress hosting
Should move out later:
- legacy direct runtime polling dependency
- deferred OSC commit behavior that has since moved behind coordinator-facing outcomes
- any remaining direct state-broadcast decisions tied to runtime internals
Current ControlServer
Should become primarily:
- HTTP ingress adapter
- WebSocket session host
- static asset/doc host if that remains embedded
The callback table in current code:
is a useful migration aid, but long-term it should evolve from callback-per-action toward typed action emission.
Current OscServer
Should remain transport-focused.
Its clean long-term responsibilities are:
- UDP socket lifecycle
- OSC frame decode
- syntactic route extraction
- emitting decoded automation payloads into the
ControlServicesshell
It should not own any runtime state semantics beyond ingress decoding.
Migration Plan
The safest migration is incremental.
Step 1. Name The Boundary Explicitly
Create and use the ControlServices name in docs and future interfaces before moving all logic.
This document is part of that step.
Step 2. Convert Callback Thinking Into Action Thinking
Without changing all runtime code at once, introduce typed action/event shapes for the major ingress paths.
The goal is for transports to emit actions, even if temporary adapters still call into existing code.
Step 3. Extract Service Hosting From OpenGLComposite
OpenGLComposite currently owns RuntimeServices startup and consumption:
That should move toward a composition root or subsystem host arrangement where render is no longer the owner of control ingress.
Step 4. Remove Direct Runtime Mutation Dependency
Previous polling and deferred OSC commit work directly against runtime storage:
That has been routed through coordinator-facing actions; later phases should replace the remaining polling shape with event-driven flows.
Step 5. Split Out Observation Delivery
WebSocket outbound delivery can stay in ControlServices, but serialization ownership should move toward the owning subsystems so the service layer stops assembling authoritative state itself.
Risks
Risk 1. Recreating RuntimeHost Coupling Under A New Name
If ControlServices is allowed to keep direct knowledge of runtime mutation internals, it will become a renamed version of the same coupling problem.
Mitigation:
- keep the boundary strict
- route mutations through coordinator interfaces
- treat any direct runtime mutation calls as migration-only compatibility
Risk 2. Service Queues Becoming Hidden State Authority
Latest-value OSC maps and reload debounce flags are appropriate here. Full committed runtime state is not.
Mitigation:
- define ingress-local versus authoritative state explicitly
- bound queues
- publish queue metrics into telemetry
Risk 3. WebSocket Broadcast Path Reintroducing Heavy Synchronous Work
If ControlServices becomes the place where whole runtime state is rebuilt or serialized on every input, it will recreate timing stalls.
Mitigation:
- broadcast snapshots produced elsewhere
- collapse redundant outbound requests
- track serialization/broadcast timing in telemetry
Risk 4. Polling Surviving Too Long As Architecture
Some polling may remain during migration, but it should not become the permanent contract.
Mitigation:
- isolate polling behind ingress interfaces
- make replacement with event-driven flows a planned Phase 2/3 outcome
Open Questions
- Should the embedded static UI/docs hosting stay inside
ControlServices, or move to a thinner app-shell concern while control APIs remain inControlServices? - Should outbound state for WebSocket clients be one combined payload or separate runtime and health channels?
- How much route/key resolution should happen in
ControlServicesversusRuntimeCoordinator? - Should any deferred automation-settle delivery remain in
ControlServices, or should all commit/settle policy move entirely into coordinator/render ownership once the live-state model is formalized? - When file watching is modernized, should reload classification live entirely in
ControlServices, or should it emit a lower-levelFilesChangedevent and letRuntimeCoordinatordecide reload semantics? - Will future non-OSC automation sources reuse the same
AutomationTargetReceivedpath, or need source-specific typed actions for policy reasons?
Short Version
ControlServices should become the app's clean ingress boundary:
- transport handling stays here
- input normalization stays here
- ingress-local buffering stays here
- mutation policy does not
- authoritative runtime state does not
- render-local transient state does not
If later phases keep that line sharp, the app gains a control layer that is fast, testable, and timing-aware without becoming another shared state authority.