# 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`, 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:

- `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:

- 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:

- [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/composite/OpenGLComposite.cpp:312)
- [OpenGLComposite.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/gl/composite/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 Runtime Mutation Dependency

Previous polling and deferred OSC commit work directly against runtime storage:

- [RuntimeServices.cpp](/c:/Users/Aiden/Documents/GitHub/video-shader-toys/apps/LoopThroughWithOpenGLCompositing/control/RuntimeServices.cpp:194)

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 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.
