# State Management API Specification

## Overview

This specification defines a RESTful API pattern for managing application state across distributed components using the Acequia framework. The API provides a uniform interface for reading, writing, and subscribing to state changes.

## Design Principles

1. **Resource-oriented**: Each setting is a distinct resource with its own URL
2. **RESTful semantics**: Use standard HTTP methods (GET, PUT) for operations
3. **Pub/Sub model**: Webhook subscriptions for real-time state change notifications
4. **Self-descriptive**: URLs clearly indicate the resource hierarchy
5. **Stateless**: Each request contains all necessary information
6. **Atomic operations**: Each setting can be read/written independently

## URL Structure

All state endpoints follow this pattern:

```
/{instanceId}/state/{settingName}
/{instanceId}/state/{settingName}/subscribe
/{instanceId}/state/{settingName}/unsubscribe
```

### Components

- `{instanceId}`: Unique identifier for the application instance (e.g., "sandtable-1", "controller-mobile-2")
- `{settingName}`: Name of the state property (e.g., "pitch", "bearing", "terrain3D")

## Endpoint Specifications

### 1. Read State (GET)

**Endpoint**: `GET /{instanceId}/state/{settingName}`

**Purpose**: Retrieve the current value of a specific state property

**Request**:
```http
GET /sandtable-1/state/pitch HTTP/1.1
Host: example.com
Accept: application/json
```

**Response** (Success):
```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "value": 45,
  "timestamp": "2025-10-07T10:30:00.000Z",
  "instanceId": "sandtable-1",
  "settingName": "pitch"
}
```

**Response** (Not Found):
```http
HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "error": "Setting not found",
  "settingName": "invalidSetting",
  "message": "The requested setting does not exist"
}
```

**Response Schema**:
```json
{
  "value": "<any>",           // The actual value (type depends on setting)
  "timestamp": "ISO8601",     // When this value was last updated
  "instanceId": "string",     // Instance that owns this state
  "settingName": "string",    // Name of the setting
  "metadata": {               // Optional metadata
    "unit": "string",         // Unit of measurement (e.g., "degrees", "meters")
    "range": {                // Valid range (for numeric values)
      "min": "number",
      "max": "number"
    },
    "type": "string"          // Value type (e.g., "number", "boolean", "string")
  }
}
```

### 2. Update State (PUT)

**Endpoint**: `PUT /{instanceId}/state/{settingName}`

**Purpose**: Update the value of a specific state property

**Request**:
```http
PUT /sandtable-1/state/pitch HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "value": 60
}
```

**Request Schema**:
```json
{
  "value": "<any>",           // Required: The new value to set
  "source": "string",         // Optional: Identifier of the requester
  "reason": "string"          // Optional: Reason for the change
}
```

**Response** (Success):
```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "value": 60,
  "previousValue": 45,
  "timestamp": "2025-10-07T10:35:00.000Z",
  "instanceId": "sandtable-1",
  "settingName": "pitch"
}
```

**Response** (Validation Error):
```http
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": "Validation failed",
  "settingName": "pitch",
  "message": "Value must be between 0 and 85",
  "received": 120,
  "constraints": {
    "type": "number",
    "min": 0,
    "max": 85
  }
}
```

**Response** (Unauthorized):
```http
HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "error": "Forbidden",
  "message": "This setting cannot be modified remotely"
}
```

### 3. Subscribe to State Changes (PUT)

**Endpoint**: `PUT /{instanceId}/state/{settingName}/subscribe`

**Purpose**: Register a webhook URL to receive notifications when the setting changes

**Request**:
```http
PUT /sandtable-1/state/pitch/subscribe HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "webhookUrl": "https://controller.example.com/webhooks/pitch",
  "subscriberId": "controller-mobile-1",
  "filters": {
    "minChange": 1,           // Optional: Only notify if change >= 1 degree
    "throttle": 500           // Optional: Max one notification per 500ms
  }
}
```

**Request Schema**:
```json
{
  "webhookUrl": "string",     // Required: URL to POST notifications to
  "subscriberId": "string",   // Required: Unique identifier for this subscriber
  "filters": {                // Optional: Conditions for notifications
    "minChange": "number",    // Minimum change magnitude to trigger notification
    "throttle": "number",     // Minimum milliseconds between notifications
    "conditions": "object"    // Custom conditions (setting-specific)
  },
  "metadata": {               // Optional: Arbitrary metadata
    "description": "string",
    "tags": ["array"]
  }
}
```

**Response** (Success):
```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "subscriptionId": "sub-1696704600000-abc123",
  "webhookUrl": "https://controller.example.com/webhooks/pitch",
  "subscriberId": "controller-mobile-1",
  "settingName": "pitch",
  "filters": {
    "minChange": 1,
    "throttle": 500
  },
  "createdAt": "2025-10-07T10:36:00.000Z"
}
```

**Response** (Invalid URL):
```http
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": "Invalid webhook URL",
  "message": "Webhook URL must be a valid HTTP/HTTPS URL"
}
```

### 4. Unsubscribe from State Changes (PUT)

**Endpoint**: `PUT /{instanceId}/state/{settingName}/unsubscribe`

**Purpose**: Remove a webhook subscription

**Request**:
```http
PUT /sandtable-1/state/pitch/unsubscribe HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "subscriptionId": "sub-1696704600000-abc123"
}
```

**Alternate Request** (by subscriberId):
```http
PUT /sandtable-1/state/pitch/unsubscribe HTTP/1.1
Host: example.com
Content-Type: application/json

{
  "subscriberId": "controller-mobile-1"
}
```

**Request Schema**:
```json
{
  "subscriptionId": "string",  // Option 1: Unsubscribe by subscription ID
  "subscriberId": "string"     // Option 2: Unsubscribe by subscriber ID (removes all)
}
```

**Response** (Success):
```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "unsubscribed": 1,
  "subscriptionIds": ["sub-1696704600000-abc123"],
  "message": "Subscription removed successfully"
}
```

**Response** (Not Found):
```http
HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "error": "Subscription not found",
  "subscriptionId": "sub-invalid",
  "message": "No active subscription found with this ID"
}
```

## Webhook Notification Format

When a subscribed setting changes, the webhook URL receives a POST request:

**Notification POST**:
```http
POST /webhooks/pitch HTTP/1.1
Host: controller.example.com
Content-Type: application/json
X-Subscription-Id: sub-1696704600000-abc123
X-Setting-Name: pitch
X-Instance-Id: sandtable-1

{
  "event": "state.changed",
  "instanceId": "sandtable-1",
  "settingName": "pitch",
  "value": 60,
  "previousValue": 45,
  "timestamp": "2025-10-07T10:40:00.000Z",
  "subscriptionId": "sub-1696704600000-abc123",
  "change": {
    "delta": 15,
    "type": "increase"
  },
  "source": "user-interaction"  // Optional: what triggered the change
}
```

**Notification Schema**:
```json
{
  "event": "state.changed",
  "instanceId": "string",
  "settingName": "string",
  "value": "<any>",
  "previousValue": "<any>",
  "timestamp": "ISO8601",
  "subscriptionId": "string",
  "change": {
    "delta": "number",        // For numeric values
    "type": "string"          // "increase", "decrease", "changed"
  },
  "source": "string",         // What triggered the change
  "metadata": "object"        // Additional context
}
```

**Expected Response**:
```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "received": true
}
```

## Discovery Endpoint

### List Available Settings

**Endpoint**: `GET /{instanceId}/state`

**Purpose**: Discover all available state properties and their metadata

**Request**:
```http
GET /sandtable-1/state HTTP/1.1
Host: example.com
Accept: application/json
```

**Response**:
```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "instanceId": "sandtable-1",
  "settings": {
    "pitch": {
      "value": 45,
      "type": "number",
      "unit": "degrees",
      "range": { "min": 0, "max": 85 },
      "writable": true,
      "subscribable": true,
      "description": "Camera pitch angle",
      "lastUpdated": "2025-10-07T10:35:00.000Z"
    },
    "bearing": {
      "value": 180,
      "type": "number",
      "unit": "degrees",
      "range": { "min": 0, "max": 360 },
      "writable": true,
      "subscribable": true,
      "description": "Camera bearing (rotation)",
      "lastUpdated": "2025-10-07T10:30:00.000Z"
    },
    "roll": {
      "value": 0,
      "type": "number",
      "unit": "degrees",
      "range": { "min": -180, "max": 180 },
      "writable": true,
      "subscribable": true,
      "description": "Camera roll angle",
      "lastUpdated": "2025-10-07T10:00:00.000Z"
    },
    "terrain3D": {
      "value": true,
      "type": "boolean",
      "writable": true,
      "subscribable": true,
      "description": "Enable 3D terrain visualization",
      "lastUpdated": "2025-10-07T09:00:00.000Z"
    },
    "terrainExaggeration": {
      "value": 2.0,
      "type": "number",
      "range": { "min": 0.1, "max": 5.0 },
      "writable": true,
      "subscribable": true,
      "description": "Terrain elevation exaggeration factor",
      "lastUpdated": "2025-10-07T09:05:00.000Z"
    }
  },
  "subscriptions": {
    "active": 3,
    "total": 5
  },
  "capabilities": {
    "supportsWebhooks": true,
    "supportsFilters": true,
    "supportsThrottling": true,
    "maxSubscriptionsPerSetting": 10
  }
}
```

## Subscription Management Endpoint

### List Subscriptions

**Endpoint**: `GET /{instanceId}/state/{settingName}/subscriptions`

**Purpose**: List all active subscriptions for a specific setting

**Request**:
```http
GET /sandtable-1/state/pitch/subscriptions HTTP/1.1
Host: example.com
Accept: application/json
```

**Response**:
```http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "settingName": "pitch",
  "subscriptions": [
    {
      "subscriptionId": "sub-1696704600000-abc123",
      "subscriberId": "controller-mobile-1",
      "webhookUrl": "https://controller.example.com/webhooks/pitch",
      "filters": {
        "minChange": 1,
        "throttle": 500
      },
      "createdAt": "2025-10-07T10:36:00.000Z",
      "lastNotified": "2025-10-07T10:40:00.000Z",
      "notificationCount": 5,
      "status": "active"
    },
    {
      "subscriptionId": "sub-1696704700000-def456",
      "subscriberId": "controller-desktop-1",
      "webhookUrl": "https://desktop.example.com/api/state/pitch",
      "filters": {},
      "createdAt": "2025-10-07T10:38:00.000Z",
      "lastNotified": "2025-10-07T10:40:00.000Z",
      "notificationCount": 2,
      "status": "active"
    }
  ],
  "total": 2
}
```

## Error Responses

All endpoints use standard HTTP status codes and return consistent error responses:

```json
{
  "error": "string",          // Error type (e.g., "Validation failed")
  "message": "string",        // Human-readable error message
  "details": "object",        // Additional error details
  "timestamp": "ISO8601",     // When the error occurred
  "requestId": "string"       // Unique request identifier for debugging
}
```

### Common Status Codes

- `200 OK` - Request succeeded
- `400 Bad Request` - Invalid request format or validation failure
- `403 Forbidden` - Operation not allowed
- `404 Not Found` - Setting or subscription not found
- `409 Conflict` - Subscription already exists
- `422 Unprocessable Entity` - Valid format but business logic error
- `429 Too Many Requests` - Rate limit exceeded
- `500 Internal Server Error` - Server-side error
- `503 Service Unavailable` - Service temporarily unavailable

## Implementation Example

### Client Usage (JavaScript)

```javascript
class StateClient {
  constructor(instanceId, baseUrl) {
    this.instanceId = instanceId
    this.baseUrl = baseUrl
  }

  // Get current state value
  async getState(settingName) {
    const response = await fetch(
      `${this.baseUrl}/${this.instanceId}/state/${settingName}`,
      { method: 'GET', headers: { 'Accept': 'application/json' } }
    )
    if (!response.ok) throw new Error(`Failed to get ${settingName}`)
    return await response.json()
  }

  // Update state value
  async setState(settingName, value, options = {}) {
    const response = await fetch(
      `${this.baseUrl}/${this.instanceId}/state/${settingName}`,
      {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ value, ...options })
      }
    )
    if (!response.ok) throw new Error(`Failed to set ${settingName}`)
    return await response.json()
  }

  // Subscribe to state changes
  async subscribe(settingName, webhookUrl, subscriberId, filters = {}) {
    const response = await fetch(
      `${this.baseUrl}/${this.instanceId}/state/${settingName}/subscribe`,
      {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ webhookUrl, subscriberId, filters })
      }
    )
    if (!response.ok) throw new Error(`Failed to subscribe to ${settingName}`)
    return await response.json()
  }

  // Unsubscribe from state changes
  async unsubscribe(settingName, subscriptionId) {
    const response = await fetch(
      `${this.baseUrl}/${this.instanceId}/state/${settingName}/unsubscribe`,
      {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ subscriptionId })
      }
    )
    if (!response.ok) throw new Error(`Failed to unsubscribe from ${settingName}`)
    return await response.json()
  }

  // Discover available settings
  async discover() {
    const response = await fetch(
      `${this.baseUrl}/${this.instanceId}/state`,
      { method: 'GET', headers: { 'Accept': 'application/json' } }
    )
    if (!response.ok) throw new Error('Failed to discover settings')
    return await response.json()
  }
}

// Usage
const client = new StateClient('sandtable-1', 'https://api.example.com')

// Get current pitch
const pitchState = await client.getState('pitch')
console.log('Current pitch:', pitchState.value)

// Set pitch to 60 degrees
await client.setState('pitch', 60)

// Subscribe to pitch changes
const subscription = await client.subscribe(
  'pitch',
  'https://myapp.com/webhooks/pitch',
  'my-app-1',
  { minChange: 5, throttle: 1000 }
)
console.log('Subscription ID:', subscription.subscriptionId)

// Later: unsubscribe
await client.unsubscribe('pitch', subscription.subscriptionId)
```

### Server Implementation (Route Handler)

```javascript
// Register state endpoint for a setting
registerStateEndpoint(settingName, config = {}) {
  const {
    type = 'any',
    validator = null,
    getter = null,
    setter = null,
    writable = true,
    subscribable = true,
  } = config

  // GET state value
  this.group.registerRoute(
    `/${this.instanceId}/state/${settingName}`,
    async (data) => {
      const method = data.method || 'GET'

      if (method === 'GET') {
        const value = getter ? getter() : this.getStateValue(settingName)
        return this.createResponse(200, 'application/json', {
          value,
          timestamp: new Date().toISOString(),
          instanceId: this.instanceId,
          settingName,
          metadata: this.getSettingMetadata(settingName)
        })
      }

      if (method === 'PUT') {
        if (!writable) {
          return this.createResponse(403, 'application/json', {
            error: 'Forbidden',
            message: 'This setting is read-only'
          })
        }

        const body = this.parseRequestBody(data)
        const { value, source, reason } = body

        // Validate
        if (validator && !validator(value)) {
          return this.createResponse(400, 'application/json', {
            error: 'Validation failed',
            message: 'Invalid value for this setting'
          })
        }

        const previousValue = getter ? getter() : this.getStateValue(settingName)

        // Apply the change
        if (setter) {
          setter(value)
        } else {
          this.setStateValue(settingName, value)
        }

        // Notify subscribers
        if (subscribable) {
          this.notifySubscribers(settingName, value, previousValue, source)
        }

        return this.createResponse(200, 'application/json', {
          success: true,
          value,
          previousValue,
          timestamp: new Date().toISOString(),
          instanceId: this.instanceId,
          settingName
        })
      }

      return this.createResponse(405, 'application/json', {
        error: 'Method not allowed'
      })
    }
  )

  // Subscribe endpoint
  if (subscribable) {
    this.registerSubscriptionEndpoints(settingName)
  }
}
```

## Workflow Examples

### Example 1: Controller Synchronizing with SandTable

```
1. Controller connects to SandTable
2. Controller discovers available settings:
   GET /sandtable-1/state

3. Controller subscribes to camera orientation changes:
   PUT /sandtable-1/state/pitch/subscribe
   PUT /sandtable-1/state/bearing/subscribe
   PUT /sandtable-1/state/roll/subscribe

4. Controller queries current values:
   GET /sandtable-1/state/pitch
   GET /sandtable-1/state/bearing
   GET /sandtable-1/state/roll

5. User adjusts pitch slider on controller:
   PUT /sandtable-1/state/pitch { value: 60 }

6. SandTable updates camera and notifies all subscribers:
   POST https://controller.example.com/webhooks/pitch
   {
     "event": "state.changed",
     "settingName": "pitch",
     "value": 60,
     "previousValue": 45,
     ...
   }
```

### Example 2: Multiple Controllers with Conflict Resolution

```
Controller A and Controller B both connected to same SandTable

1. Both subscribe to 'pitch' changes
2. Controller A sets pitch to 45
3. Both controllers receive webhook notification
4. Controller B sets pitch to 60 (while A's update is in flight)
5. Both controllers receive webhook notification with value: 60
6. Controllers update their UI to show synchronized value
```

### Example 3: Filtered Subscriptions

```
Controller wants high-frequency updates, Dashboard wants summary

Controller subscription:
PUT /sandtable-1/state/bearing/subscribe
{
  "webhookUrl": "https://controller.example.com/webhooks/bearing",
  "subscriberId": "controller-1",
  "filters": {
    "throttle": 100  // Max 10 updates/second
  }
}

Dashboard subscription:
PUT /sandtable-1/state/bearing/subscribe
{
  "webhookUrl": "https://dashboard.example.com/api/bearing",
  "subscriberId": "dashboard-1",
  "filters": {
    "minChange": 10,  // Only notify on changes >= 10 degrees
    "throttle": 5000  // Max 1 update per 5 seconds
  }
}
```

## Migration Path

### Current Implementation → Spec Compliance

1. **Phase 1**: Alias existing routes
   - Keep `/settings/{name}` working
   - Add `/state/{name}` as new preferred path

2. **Phase 2**: Standardize response format
   - Wrap values in standard envelope
   - Add metadata and timestamps

3. **Phase 3**: Implement subscription endpoints
   - Add `/subscribe` and `/unsubscribe` routes
   - Migrate webhook logic to new pattern

4. **Phase 4**: Add discovery endpoint
   - Implement `GET /state` for capability discovery

5. **Phase 5**: Deprecate old routes
   - Mark `/settings/*` as deprecated
   - Eventually remove after migration period

## Security Considerations

1. **Authentication**: Implement token-based auth for state modifications
2. **Authorization**: Role-based access control for sensitive settings
3. **Rate Limiting**: Prevent abuse of subscription endpoints
4. **Webhook Validation**: Verify webhook URLs before accepting subscriptions
5. **HTTPS Only**: Enforce HTTPS for webhook URLs
6. **Subscription Limits**: Cap number of subscriptions per setting/subscriber
7. **Audit Logging**: Log all state changes with source information

## Benefits

1. **Consistency**: Uniform pattern across all settings
2. **Discoverability**: Clients can enumerate available settings
3. **Decoupling**: Publishers don't need to know about subscribers
4. **Scalability**: Supports multiple controllers efficiently
5. **Debuggability**: Standard formats make troubleshooting easier
6. **Extensibility**: Easy to add new settings without changing API pattern
7. **Real-time**: Webhook subscriptions enable instant synchronization

## Versioning

Consider adding version parameter for future evolution:

```
/v1/{instanceId}/state/{settingName}
/v2/{instanceId}/state/{settingName}
```

This allows breaking changes while maintaining backward compatibility.
