# Tasklife Realtime Gateway v1 Contract

> **Status:** Drafted for RT-1 (`#837`)
> **Purpose:** Replace the current SSE + DB event-store bridge with a true zero-polling realtime transport.

## Goal

Deliver self-hosted, org-safe, project-scoped realtime updates for Tasklife boards without:

- DB polling loops
- SSE replay cursors
- polling fallback in the board UI
- Redis dependency in v1

This contract defines the behavior that later implementation tasks must follow so backend, gateway, and frontend do not invent different protocols.

## Current implementation being replaced

Current codepaths are useful only as migration context:

- `main/api/realtime/stream.php` — session-auth SSE endpoint with DB polling and `last_event_id`
- `main/api/lib/RealtimeEventPublisher.php` — posts live events to the realtime gateway after successful writes, while still appending legacy SSE stream events during the transition
- `main/api/lib/RealtimeEventStore.php` — DB-backed append + fetch bridge for SSE delivery
- `main/js/realtime_stream_client.js` — websocket client with channel auth, reconnect, and ping/pong heartbeat
- `main/index.php` — now patches board cards directly from websocket events; reconnect/visibility still use one-shot resync fetches when continuity is uncertain
- `main/index.php` functions `scheduleBoardRealtimeRefresh()` and `syncBoardTaskPositions()` — reconnect/visibility reconciliation path only, not the primary event application path
- `main/ajax/get_task_details.php` and `main/api/lib/TasksService.php::markTaskAsRead()` — read-state mutation paths that now emit `task.read_state.updated`
- `docs/api/realtime_sse.md` and `docs/api/api.md` — current SSE docs that will later be replaced/superseded

That path is **not** the target architecture.

## v1 architecture

### Components

1. **Tasklife PHP app**
   - Source of truth for writes
   - Authenticates browser users via normal app session
   - Mints channel auth assertions for private subscriptions
   - Generates `event_id` values before publishing
   - Publishes events to the realtime gateway only after successful writes

2. **Realtime gateway service**
   - Dedicated long-lived process
   - Maintains active connections and subscriptions in memory
   - Authenticates internal publish requests
   - Verifies browser subscription assertions
   - Fans events out immediately to matching live subscribers
   - Preserves publisher-supplied `event_id` values unchanged

3. **Browser realtime client**
   - Opens one persistent connection per board page
   - Authenticates and subscribes to allowed channels
   - Reconnects with backoff and resubscribes after reconnect
   - Updates board state directly from received events

### Explicit non-goals for v1

- No DB-backed replay queue
- No `last_event_id` resume cursor
- No polling fallback path
- No Redis / Kafka / external broker
- No multi-node clustering guarantees
- No presence or typing indicators
- No public browser access to internal publish endpoints

## Transport choice

### Browser transport

Use **WebSocket** for the dedicated gateway.

Reason:

- private channel auth is simpler to model cleanly
- bidirectional control frames are useful for auth / subscribe / ping
- it matches the “self-hosted Pusher-style” direction more honestly than stretching SSE further

### Internal app -> gateway publish transport

Use **HTTP POST** from the PHP app to the gateway for v1.

Reason:

- simple to implement from PHP write paths
- easy to secure with one boring shared secret
- sufficient for a single-node v1 gateway

## Channel model

### Allowed v1 channel shapes

#### Project board channel

`private-project-{projectId}`

Primary and only supported v1 board channel.

A board page subscribes to this channel after auth succeeds.

### Reserved but explicitly unsupported in v1

- `private-org-{orgId}`
- `private-task-{taskId}`
- `private-user-{orgUserId}`

These names are reserved for possible future expansion, but v1 gateway and auth endpoints must reject them.

### Channel rules

- Channel names are case-sensitive
- The only valid v1 channel regex is: `^private-project-([1-9][0-9]*)$`
- IDs must be positive base-10 integers with no leading zeroes
- Browser clients may request only channels they are authorized for
- The gateway must reject wildcard channel subscription attempts
- The gateway must not derive org/project permissions from client-provided payload alone; it must verify signed auth context
## Browser connection lifecycle

### 1. Connect

Browser opens one WebSocket connection to the realtime gateway.

Example URL:

`wss://tasklife.com/main/realtime/ws`

The exact domain / route wiring is an RT-7 deployment detail, but v1 requires one websocket gateway route.

### 2. Server hello

Gateway **must** send an initial hello frame immediately after the websocket opens:

```json
{
  "type": "hello",
  "connection_id": "c_01HZY8W9FW9DN4KJ3Y4B7K8V2D",
  "server_time": "2026-05-23T20:00:00Z",
  "heartbeat_interval_seconds": 25
}
```

### 3. Browser requests channel auth from Tasklife app

For each private channel, the browser requests an auth assertion from the Tasklife app using the normal logged-in session.

#### App auth endpoint

`POST /main/api/realtime/channel_auth.php`

#### App-side connection verification dependency

To validate `connection_id` without guessing, the Tasklife app uses a gateway-only internal lookup before minting the token:

`GET http://127.0.0.1:9xxx/internal/realtime/connections/{connection_id}`

Required behavior:

- authenticated with the same internal bearer secret family used by the publish endpoint
- returns `200` only when the connection currently exists and is open
- returns `404` when the connection is unknown or already closed
- returns `503` when the gateway cannot answer authoritatively
- `channel_auth.php` must not mint a token if this lookup fails

#### Request body

```json
{
  "channel": "private-project-31",
  "connection_id": "c_01HZY8W9FW9DN4KJ3Y4B7K8V2D"
}
```

#### Success response

```json
{
  "success": true,
  "data": {
    "channel": "private-project-31",
    "auth": {
      "token": "rt1.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "expires_at": "2026-05-23T20:01:00Z"
    },
    "context": {
      "org_id": 7,
      "project_id": 31,
      "user_id": 42,
      "org_user_id": 50,
      "connection_id": "c_01HZY8W9FW9DN4KJ3Y4B7K8V2D"
    }
  }
}
```

#### Failure responses

- `401` — session missing / expired
- `403` — channel requested but user not authorized
- `422` — malformed or unsupported channel name / missing `connection_id` / connection no longer open
- `413` — request body exceeds the 16 KB limit

#### Token contract

The browser channel-auth token is a signed, short-lived assertion minted by the Tasklife app.

```json
{
  "iss": "tasklife-app",
  "aud": "tasklife-realtime-gateway",
  "connection_id": "c_01HZY8W9FW9DN4KJ3Y4B7K8V2D",
  "channel": "private-project-31",
  "org_id": 7,
  "project_id": 31,
  "user_id": 42,
  "org_user_id": 50,
  "exp": 1773509100
}
```

Rules:

- Signature algorithm for v1: `HS256`
- Signing secret: shared only between Tasklife app and realtime gateway
- TTL: 60 seconds maximum from mint time
- Token is valid for exactly one `connection_id` and one `channel`
- Gateway must reject tokens with missing claims, expired `exp`, mismatched `connection_id`, mismatched `channel`, or invalid signature

### 4. Browser sends subscribe frame to gateway

```json
{
  "type": "subscribe",
  "channel": "private-project-31",
  "auth": {
    "token": "rt1.eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  }
}
```

### 5. Gateway validates and replies

#### Success

```json
{
  "type": "subscribed",
  "channel": "private-project-31"
}
```

#### Failure

```json
{
  "type": "error",
  "code": "CHANNEL_AUTH_FAILED",
  "message": "Subscription denied for private-project-31"
}
```

### 6. Live events arrive

Gateway emits `event` frames.

### 7. Disconnect / reconnect

- Browser reconnects with exponential backoff
- Browser fetches fresh channel auth after reconnect
- Browser resubscribes explicitly
- v1 does **not** request missed event replay
- On reconnect, client may do one state resync fetch if needed, but this is not polling and must not become a repeating timer loop

Backoff requirements:

- initial retry delay: 1 second
- backoff multiplier: 2x
- max delay: 15 seconds
- add jitter of up to 250ms

## Gateway frame contract

### Client -> gateway frames

#### Ping

```json
{
  "type": "ping"
}
```

#### Subscribe

```json
{
  "type": "subscribe",
  "channel": "private-project-31",
  "auth": {
    "token": "***"
  }
}
```

#### Unsubscribe

```json
{
  "type": "unsubscribe",
  "channel": "private-project-31"
}
```

### Gateway -> client frames

#### Pong

```json
{
  "type": "pong",
  "server_time": "2026-05-23T20:00:30Z"
}
```

#### Subscribed

```json
{
  "type": "subscribed",
  "channel": "private-project-31"
}
```

#### Unsubscribed

```json
{
  "type": "unsubscribed",
  "channel": "private-project-31"
}
```

#### Event

```json
{
  "type": "event",
  "channel": "private-project-31",
  "event": "task.updated",
  "data": {
    "event_id": "evt_01HZY95V0K26BQ4QCV2V7V0MWM",
    "occurred_at": "2026-05-23T20:01:12Z",
    "actor_user_id": 42,
    "org_id": 7,
    "project_id": 31,
    "entity_type": "task",
    "entity_id": 516,
    "payload": {
      "task_id": 516,
      "task": {
        "id": 516,
        "title": "RT-1: Design realtime gateway contract, channel model, and event schema",
        "project_column_id": 131,
        "column_name": "In Progress",
        "assigned_org_user_id": 50,
        "priority": null,
        "due_date": null,
        "is_read": 1
      },
      "changed_fields": ["project_column_id", "is_read"]
    }
  }
}
```

#### Error

```json
{
  "type": "error",
  "code": "INVALID_FRAME",
  "message": "Unsupported frame type"
}
```

## Failure behavior matrix

### Websocket connect failures

- gateway unavailable before websocket upgrade
  - browser treats as connect failure and enters reconnect backoff
- gateway accepts socket but cannot send `hello`
  - gateway closes socket with websocket close code `1011`

### Subscribe failures

- malformed subscribe frame
  - gateway sends `error` with `code: INVALID_FRAME`
  - socket remains open
- unsupported channel name
  - gateway sends `error` with `code: UNSUPPORTED_CHANNEL`
  - socket remains open
- expired or invalid token
  - gateway sends `error` with `code: CHANNEL_AUTH_FAILED`
  - socket remains open
- repeated subscribe for an already-subscribed channel
  - gateway responds with `subscribed`
  - operation is idempotent

### Unsubscribe failures

- unsubscribe for a channel not currently subscribed
  - gateway responds with `unsubscribed`
  - operation is idempotent

### Idle / heartbeat behavior

- browser should send `ping` if no inbound frame arrives within `heartbeat_interval_seconds`
- gateway must reply with `pong`
- gateway may also send websocket ping control frames at transport level
- if no valid client activity or successful ping/pong exchange occurs within 2 heartbeat intervals, gateway may close the socket with code `1001`

### Internal publish failures

- missing or invalid publish auth header
  - response `401`
- malformed JSON body
  - response `422`
- structurally valid request with unsupported `channel` or `event`
  - response `422`
- gateway temporary internal failure
  - response `503`

### Publish success semantics

- `delivered_to: 0` is still a successful publish when no clients are subscribed
- success means the gateway accepted and processed the event, not that any specific browser acknowledged it

## Event names

### Required v1 event names

- `task.created`
- `task.updated`
- `task.moved`
- `comment.created`
- `task.read_state.updated`

### Event naming rules

- lower-case
- dot-separated namespace style
- stable once shipped
- event name describes business meaning, not transport behavior

## Event envelope contract

Every event delivered to the browser must contain the same top-level envelope.

```json
{
  "event_id": "evt_01HZY95V0K26BQ4QCV2V7V0MWM",
  "occurred_at": "2026-05-23T20:01:12Z",
  "actor_user_id": 42,
  "org_id": 7,
  "project_id": 31,
  "entity_type": "task",
  "entity_id": 516,
  "payload": {}
}
```

### Envelope field rules

- `event_id`
  - globally unique string ID generated by the **publisher payload in the Tasklife app**, not by the gateway
  - gateway must preserve the publisher-supplied `event_id` unchanged end-to-end
  - opaque to clients
- `occurred_at`
  - UTC ISO-8601 timestamp
- `actor_user_id`
  - may be `null` for system-generated events
- `org_id`
  - mandatory
- `project_id`
  - mandatory for board events
- `entity_type`
  - one of `task`, `comment`
- `entity_id`
  - integer ID of the entity named by `entity_type`
- `payload`
  - event-specific object

## Event payload shape excerpts (non-normative)

These examples show the semantic fields each event carries.
For the exact websocket frames that must be emitted on the wire, use the **Full on-wire websocket event examples** section later in this document.


### `task.created`

```json
{
  "task_id": 516,
  "task": {
    "id": 516,
    "title": "Implement gateway health endpoint",
    "project_id": 31,
    "project_column_id": 130,
    "column_name": "To Do",
    "assigned_org_user_id": 50,
    "task_type_id": null,
    "priority": "High",
    "due_date": null,
    "is_read": 0,
    "comment_count": 0,
    "child_count": 0
  }
}
```

### `task.updated`

```json
{
  "task_id": 516,
  "task": {
    "id": 516,
    "title": "Implement gateway health endpoint",
    "project_id": 31,
    "project_column_id": 131,
    "column_name": "In Progress",
    "assigned_org_user_id": 50,
    "assignee_name": "Stracker Coding Agent",
    "assignee_avatar_url": "/uploads/avatars/agent-42.png",
    "task_type_id": null,
    "priority": "High",
    "due_date": null,
    "is_read": 1,
    "comment_count": 2,
    "child_count": 0
  },
  "changed_fields": ["project_column_id", "is_read", "comment_count"]
}
```

### `task.moved`

```json
{
  "task_id": 516,
  "task": {
    "id": 516,
    "title": "Implement gateway health endpoint",
    "project_id": 31,
    "project_column_id": 136,
    "column_name": "In QA",
    "assigned_org_user_id": 50,
    "assignee_name": "Stracker Coding Agent",
    "assignee_avatar_url": "/uploads/avatars/agent-42.png",
    "task_type_id": null,
    "priority": "High",
    "due_date": null,
    "is_read": 1,
    "comment_count": 2,
    "child_count": 0
  },
  "from_project_id": 31,
  "from_column_id": 131,
  "from_column_name": "In Progress",
  "to_project_id": 31,
  "to_column_id": 136,
  "to_column_name": "In QA"
}
```

### `comment.created`

```json
{
  "task_id": 516,
  "comment_id": 2424,
  "task": {
    "id": 516,
    "project_id": 31,
    "project_column_id": 131,
    "column_name": "In Progress"
  },
  "comment": {
    "id": 2424,
    "parent_comment_id": null,
    "author_user_id": 42,
    "created_at": "2026-05-23T20:04:00Z",
    "body_markdown": "Implemented initial gateway contract draft"
  }
}
```

### `task.read_state.updated`

```json
{
  "task_id": 516,
  "is_read": 1,
  "reader_org_user_id": 50,
  "task": {
    "id": 516,
    "project_id": 31,
    "project_column_id": 131,
    "column_name": "In Progress",
    "assigned_org_user_id": 50,
    "assignee_name": "Stracker Coding Agent",
    "assignee_avatar_url": "/uploads/avatars/agent-42.png",
    "comment_count": 2,
    "child_count": 0,
    "is_read": 1
  }
}
```

Read-state emission rules:

- emit only on an actual `0 -> 1` transition
- never emit for a non-assignee viewer
- v1 does not define an unread/reset event
- the event is published to the project channel because board cards in that project display the read indicator

## Full on-wire websocket event examples

### `task.created`

```json
{
  "type": "event",
  "channel": "private-project-31",
  "event": "task.created",
  "data": {
    "event_id": "evt_01HZZ0CREATED",
    "occurred_at": "2026-05-23T20:10:00Z",
    "actor_user_id": 42,
    "org_id": 7,
    "project_id": 31,
    "entity_type": "task",
    "entity_id": 516,
    "payload": {
      "task_id": 516,
      "task": {
        "id": 516,
        "title": "Implement gateway health endpoint",
        "project_id": 31,
        "project_column_id": 130,
        "column_name": "To Do",
        "assigned_org_user_id": 50,
        "assignee_name": "Stracker Coding Agent",
        "assignee_avatar_url": "/uploads/avatars/agent-42.png",
        "task_type_id": null,
        "priority": "High",
        "due_date": null,
        "is_read": 0,
        "comment_count": 0,
        "child_count": 0
      }
    }
  }
}
```

### `task.updated`

```json
{
  "type": "event",
  "channel": "private-project-31",
  "event": "task.updated",
  "data": {
    "event_id": "evt_01HZZ0UPDATED",
    "occurred_at": "2026-05-23T20:11:00Z",
    "actor_user_id": 42,
    "org_id": 7,
    "project_id": 31,
    "entity_type": "task",
    "entity_id": 516,
    "payload": {
      "task_id": 516,
      "task": {
        "id": 516,
        "title": "Implement gateway health endpoint",
        "project_id": 31,
        "project_column_id": 131,
        "column_name": "In Progress",
        "assigned_org_user_id": 50,
        "assignee_name": "Stracker Coding Agent",
        "assignee_avatar_url": "/uploads/avatars/agent-42.png",
        "task_type_id": null,
        "priority": "High",
        "due_date": null,
        "is_read": 1,
        "comment_count": 2,
        "child_count": 0
      },
      "changed_fields": ["project_column_id", "is_read", "comment_count"]
    }
  }
}
```

### `task.moved`

```json
{
  "type": "event",
  "channel": "private-project-31",
  "event": "task.moved",
  "data": {
    "event_id": "evt_01HZZ0MOVED",
    "occurred_at": "2026-05-23T20:12:00Z",
    "actor_user_id": 42,
    "org_id": 7,
    "project_id": 31,
    "entity_type": "task",
    "entity_id": 516,
    "payload": {
      "task_id": 516,
      "task": {
        "id": 516,
        "title": "Implement gateway health endpoint",
        "project_id": 31,
        "project_column_id": 136,
        "column_name": "In QA",
        "assigned_org_user_id": 50,
        "assignee_name": "Stracker Coding Agent",
        "assignee_avatar_url": "/uploads/avatars/agent-42.png",
        "task_type_id": null,
        "priority": "High",
        "due_date": null,
        "is_read": 1,
        "comment_count": 2,
        "child_count": 0
      },
      "from_column_id": 131,
      "from_column_name": "In Progress",
      "to_column_id": 136,
      "to_column_name": "In QA",
      "sort_order_hint": {
        "before_task_id": 514,
        "after_task_id": 519
      }
    }
  }
}
```

### `comment.created`

```json
{
  "type": "event",
  "channel": "private-project-31",
  "event": "comment.created",
  "data": {
    "event_id": "evt_01HZZ0COMMENT",
    "occurred_at": "2026-05-23T20:13:00Z",
    "actor_user_id": 42,
    "org_id": 7,
    "project_id": 31,
    "entity_type": "comment",
    "entity_id": 2424,
    "payload": {
      "task_id": 516,
      "comment_id": 2424,
      "task": {
        "id": 516,
        "project_id": 31,
        "project_column_id": 131,
        "column_name": "In Progress"
      },
      "comment": {
        "id": 2424,
        "parent_comment_id": null,
        "author_user_id": 42,
        "created_at": "2026-05-23T20:13:00Z",
        "body_markdown": "Implemented initial gateway contract draft"
      }
    }
  }
}
```

### `task.read_state.updated`

```json
{
  "type": "event",
  "channel": "private-project-31",
  "event": "task.read_state.updated",
  "data": {
    "event_id": "evt_01HZZ0READ",
    "occurred_at": "2026-05-23T20:14:00Z",
    "actor_user_id": 42,
    "org_id": 7,
    "project_id": 31,
    "entity_type": "task",
    "entity_id": 516,
    "payload": {
      "task_id": 516,
      "is_read": 1,
      "reader_org_user_id": 50,
      "task": {
        "id": 516,
        "project_id": 31,
        "project_column_id": 131,
        "column_name": "In Progress",
        "assigned_org_user_id": 50,
        "assignee_name": "Stracker Coding Agent",
        "assignee_avatar_url": "/uploads/avatars/agent-42.png",
        "comment_count": 2,
        "child_count": 0,
        "is_read": 1
      }
    }
  }
}
```

## Internal publish contract

### Endpoint

`POST http://127.0.0.1:9xxx/internal/realtime/publish`

The exact port is an RT-7 deployment detail. The contract below is fixed.

### Auth

Every internal publish request must include:

- `Authorization: Bearer <shared-internal-secret>`

v1 does **not** allow alternate internal auth schemes. No HMAC variant, no query-token fallback, no cookie auth.

### Publish request body

```json
{
  "channel": "private-project-31",
  "event": "task.updated",
  "data": {
    "event_id": "evt_01HZY95V0K26BQ4QCV2V7V0MWM",
    "occurred_at": "2026-05-23T20:01:12Z",
    "actor_user_id": 42,
    "org_id": 7,
    "project_id": 31,
    "entity_type": "task",
    "entity_id": 516,
    "payload": {
      "task_id": 516,
      "task": {
        "id": 516,
        "title": "Implement gateway health endpoint",
        "project_column_id": 131,
        "column_name": "In Progress",
        "assigned_org_user_id": 50,
        "is_read": 1
      },
      "changed_fields": ["is_read"]
    }
  }
}
```

### Success response

```json
{
  "success": true,
  "delivered_to": 3
}
```

### Failure response

```json
{
  "success": false,
  "error": "unauthorized"
}
```

### Publish rules

- gateway must reject a publish when `channel`, `data.project_id`, and `data.org_id` fail internal consistency checks for the target subscription domain
- App publishes **only after** the primary write succeeds
- Publish failure must be logged
- Publish failure must not corrupt the underlying write result
- Publish requests must never be exposed to browser clients
- Publish endpoint must be reachable only on loopback or a private internal network path, never on the public internet without an upstream allowlist
- RT-4 must replace the current storage-backed `RealtimeEventPublisher` behavior across all existing publisher callsites, including `create_task.php`, `update_task.php`, `update_task_column.php`, `update_task_order.php`, `comments.php`, `get_task_details.php`, `TasksService::markTaskAsRead()`, and the `/external-api/v1` task mutation routes that flow through `TasksService` (`PUT /v1/tasks/{id}`, `PUT /v1/tasks/{id}/status`, and equivalent PATCH forms)
- preferred implementation point: shared service-layer publish hooks, so legacy AJAX and `/api/v1` mutations cannot drift

## Authorization rules

### App-side authorization for channel auth endpoint

#### `private-project-{projectId}`

Allow only if:

- user has a valid session
- `projectId` exists
- project belongs to session org
- user is authorized to view that project in the normal app
- requested `connection_id` matches a currently open unauthenticated or partially authenticated websocket tracked by the gateway

### Gateway-side authorization

The gateway does not trust raw channel names from the client.

It verifies:

- token signature
- token expiry
- token connection binding
- token channel match
- org / project context match

If any check fails, subscription is rejected.

## Read-model behavior in the browser

### Board page rules

- one persistent websocket per board page
- one project channel subscription per open board
- on `task.created`, add the new card directly from the event payload when the payload includes the minimum board patch shape below; otherwise do one one-off hydration fetch
- on `task.updated`, patch affected card fields directly from the payload
- on `task.moved`, move card between columns without full-board reload
- on `comment.created`, update comment count / indicator state
- on `task.read_state.updated`, update the assignee read indicator immediately

#### Minimum board patch shape

For direct board rendering or patching without hydration, the event payload must include these task fields:

- `id`
- `title`
- `project_id`
- `project_column_id`
- `column_name`
- `assigned_org_user_id`
- `assignee_name` (nullable)
- `assignee_avatar_url` (nullable, canonical v1 field; may be derived from current repo sources such as `assignee_photo` / `photo_url` plus avatar helpers)
- `task_type_id` (nullable)
- `priority` (nullable)
- `due_date` (nullable)
- `is_read`
- `comment_count`
- `child_count`

Hydration fallback rule:

- if any required field above is absent on `task.created`, the client performs exactly one task detail fetch against `main/ajax/get_task_details.php?task_id={id}`
- if any required field above is absent on `task.updated`, `task.moved`, or `task.read_state.updated`, that is a publisher bug and should be logged rather than silently reintroducing polling loops

Direct patch authority rules:

- `task.updated`, `task.moved`, and `task.read_state.updated` payloads are authoritative for board state patching
- `task.created` may trigger a one-off hydration fetch only when the minimum board patch shape is incomplete
- repeated timer-based refresh is forbidden once websocket transport is active
### Resync rule

After reconnect, the client may perform **one** explicit board state resync fetch if it cannot safely infer continuity.

Canonical resync endpoint in v1:

- reuse the existing board dataset endpoint selected by `currentBoardEndpointUrl()` for the active board context
- the realtime client must not invent a second board-resync endpoint in v1
- `get_task_details.php?task_id={id}` remains hydration-only for single-task fetches, not full-board resync

That resync:

- is allowed once per reconnect sequence
- must not start a recurring poller
- should clear any stale optimistic local state

## Operational guarantees and tradeoffs

### v1 guarantees

- live fanout to connected clients on a single gateway node
- org-safe channel scoping
- project-safe board subscriptions
- deterministic contract between app, gateway, and browser

### v1 non-guarantees

- durable replay after disconnect
- exactly-once delivery
- cross-node session sharing
- ordered fanout across multiple gateway instances

Clients must therefore be:

- idempotent
- tolerant of duplicate events
- able to recover with one state resync after reconnect

## Logging requirements

The gateway and app integration should log at least:

- connection opened / closed
- subscribe success / failure
- auth denial reason
- publish success / failure
- fanout recipient count
- reconnect attempts observed client-side when surfaced

Sensitive auth tokens must never be written to logs.

Additional v1 security / ops rules:

- enforce websocket `Origin` allowlist for first-party Tasklife app origins
- require the same-origin / normal CSRF posture for `channel_auth.php`; do not allow arbitrary third-party browser origins to mint channel tokens from a user session
- enforce short token TTL exactly as defined above
- limit websocket frame size to 64 KB and `channel_auth.php` request body size to 16 KB
- limit subscriptions to 5 channels per connection in v1
- rate-limit channel auth requests per session and websocket subscribe attempts per connection
- never log bearer secrets or signed channel tokens

## Guardrails

### Forbidden regressions

The implementation for RT-2 onward must not quietly backslide into:

- polling DB tables for outbound events
- SSE `last_event_id` replay as the primary architecture
- infinite board refresh loops
- “temporary” polling fallback that becomes permanent
- browser clients calling internal publish endpoints

### Required migration posture

Until cleanup task RT-9, the old SSE path may coexist in code, but the new gateway implementation must be treated as the real target path.

## Implementation map for follow-up tasks

- **RT-2** — build gateway server matching websocket + internal publish contract
- **RT-3** — implement `channel_auth.php` session-auth endpoint
- **RT-4** — replace `RealtimeEventStore` append flow with direct internal publish calls after successful writes, including read-state publish hooks from `get_task_details.php` and `TasksService::markTaskAsRead()`
- **RT-5** — replace `realtime_stream_client.js` EventSource logic with websocket client
- **RT-6** — patch board state directly from event frames and confine `scheduleBoardRealtimeRefresh()` / `syncBoardTaskPositions()` to reconnect/visibility reconciliation only
- **RT-7** — route and manage the gateway service in infra
- **RT-8** — add observability and runbook
- **RT-9** — delete SSE/event-store/polling fallback leftovers and superseded docs
- **RT-10** — verify live multi-session behavior end to end
