# ControlServices Subsystem Design This document expands the `ControlServices` subsystem described in [PHASE_1_SUBSYSTEM_BOUNDARIES_DESIGN.md](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/docs/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: - `RuntimeServices` hosts control server startup, OSC queues, deferred OSC commits, and file-watch polling: - [RuntimeServices.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.h:26) - [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:24) - `ControlServer` owns HTTP, WebSocket upgrade, static asset serving, and direct callback-based route dispatch: - [ControlServer.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.h:15) - [ControlServer.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp:88) - `OscServer` owns UDP socket receive, OSC decode, and parameter callback dispatch: - [OscServer.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/OscServer.h:11) - [OscServer.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/OscServer.cpp:58) 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/param` traffic becomes `AutomationTargetReceived` - `POST /api/layers/add` becomes `LayerAddRequested` - `POST /api/reload` becomes `ShaderReloadRequested` - file-watch changes become `RegistryChangedDetected` or `ReloadRequested` 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` and `RuntimeStore`. ### Render Snapshot Publication `ControlServices` must not publish render-facing snapshots or poke render-local structures directly. ### Render-Local Overlay Ownership Live OSC overlays, temporal state, shader feedback, and render-only transient state belong to `RenderEngine`. `ControlServices` may ingest automation targets, but it should not own how those targets are 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: - `OscIngress` - `HttpControlIngress` - `WebSocketSessionHost` - `FileWatchIngress` 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: - [OscServer.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/OscServer.cpp:95) - [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:65) - [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:82) 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: - [ControlServer.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp:163) - [ControlServer.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp:170) Target rule: - `ControlServices` may 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: - [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:194) 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: - `LayerAddRequested` - `LayerRemovedRequested` - `LayerReorderedRequested` - `LayerBypassSetRequested` - `LayerShaderSetRequested` - `ParameterSetRequested` - `LayerResetRequested` - `StackPresetSaveRequested` - `StackPresetLoadRequested` - `ShaderReloadRequested` - `ScreenshotRequested` - `AutomationTargetReceived` - `RegistryChangeDetected` ## 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: - `RuntimeSnapshotProvider` provides render-facing snapshot payloads or a runtime-state projection derived from those published snapshots - `RuntimeCoordinator` or a later runtime-read-model helper provides control-plane runtime summaries when the UI needs more than raw render state - `HealthTelemetry` provides health payloads - `ControlServices` delivers them to connected observers ## Current Code Mapping This section maps the current implementation onto the target subsystem. ### Current `RuntimeServices` Should split into: - `ControlServices` shell - 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: - direct `RuntimeHost` polling dependency - deferred OSC commit behavior as currently implemented through direct host mutation - 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: - [ControlServer.h](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.h:18) 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 `ControlServices` shell 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: - [OpenGLComposite.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp:312) - [OpenGLComposite.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp:723) 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 `RuntimeHost` Dependency Current polling and deferred OSC commit work directly against `RuntimeHost`: - [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:194) That should be replaced with coordinator-facing actions and later 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 direct host calls as temporary compatibility only ### 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 in `ControlServices`? - Should outbound state for WebSocket clients be one combined payload or separate runtime and health channels? - How much route/key resolution should happen in `ControlServices` versus `RuntimeCoordinator`? - 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-level `FilesChanged` event and let `RuntimeCoordinator` decide reload semantics? - Will future non-OSC automation sources reuse the same `AutomationTargetReceived` path, 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.