ADR-0044: Surfaces¶
Status: Accepted; partly superseded by ADR-0047 Date: 2026-09-29 Deciders: Alex Nodeland
Context¶
Events come from other services (over HTTP, in batches, from webhooks), from people and dashboards (live, over a WebSocket), from timetables, and from external agents (over MCP). Operators need to see and fix what rules and runs are doing.
ADR-0011 decided the surfaces, and its amendments changed them. This record states the decision as it stands, superseding it.
Decision¶
- Four surfaces, each a thin adapter over a
Workspacehandle. Every command goes through one handler,reflexr.workspace.execute, so they behave identically:- HTTP ingest and REST (
reflexr.fastapi): publish one event or a batch, idempotent by id; read the log; inspect and administer rules (cursor, lag, replay), runs (retry, skip, cancel) and dead letters. - WebSocket (
reflexr.fastapi): a resumable subscription with artifactr's handshake (hello, replay,replay_complete), a filter by event type, the same command frames, and the same close codes. - Schedules (
reflexr.workspace): interval and cron schedules that publish ticks into workspaces (ADR-0028). - MCP (
reflexr.mcp): publishing, reading and administration as tools, and runs as resources with tenant-qualified URIs and update notifications.
- HTTP ingest and REST (
- Authentication is the host's. Each surface takes a resolver that returns the tenant and the actor:
resolve_actor(connection)for REST and the WebSocket, andresolve(ctx)for MCP, whosectxis anMcpContext, the SDK'sContextwith its request typed as Starlette'sRequest. Rate limiting and health checks are the application's too. - Authorization within a tenant is one hook, asked in one place. The router and
ReflexrMcptake the sameauthorize(tenant_id, workspace_id, actor)(reflexr.workspace.Authorize) and pass it toWorkspaces.open(..., authorize=), which raisesForbidden("this workspace is not yours to use") before the handle exists. REST answers 403, the WebSocket closes with 4403, and MCP fails the tool call, resource read or run subscription with the message.GET /rules,GET /schedulesandlist_rulesname no workspace, so they are not asked. - What a surface reports, the workspace layer computes.
Workspace.rule_statuses()lists every registered rule (enabled, cursor, lag, generation, dead letters),Workspace.schedule_statuses()each schedule that targets the workspace with its last and next tick, andWorkspaces.schedules_for(tenant_id)the schedules as one tenant may see them, with their targets narrowed to its own workspaces. REST returns them as JSON and MCP formats them as text, so neither computes a status itself (ADR-0025). - A read is a window, a filter and a page.
Storage.read,Workspace.read,GET /workspaces/{id}/eventsand MCP'sread_eventstakeafter_seqandbefore_seqfor the windowafter_seq < seq < before_seq,types(REST's repeatedtype), andlimit(the first so many) orlast(the last so many, still oldest first).Workspace.readrefuses bothlimitandlast, or a negative number, asvalidation_failed, so storage adapters need not; each adapter filters in its store. hellotakesfrom_head. A connection withfrom_head: truestarts athead_seq, replaying nothing, andcore.resumerefuses it with a nonzeroresume_after_seq. A tail read over REST, thenhellowithresume_after_seqat the tail's lastseq, shows recent history and everything after it with no gap, so the stream needs no tail of its own.- A run subscription is checked when it opens. The MCP SDK serves
subscriptions/listenitself, so a middleware makes a resource read's checks for each run URI a request names: that the run is the client's tenant's, andauthorize. - Command ids deduplicate per participant. REST and the WebSocket remember recent results by tenant, workspace, the actor's
participantandcommand_id, so a retried command is carried out once, whichever handle of the same participant sends it. Over MCP, an event'sidmakes a publish idempotent. - Deliberate differences:
- MCP's
read_eventsandlist_runsreturn 50 and 20 when given nolimit, for a model's context, where REST returns everything. - Batch publishing and command ids are REST's and the WebSocket's.
- MCP has no tool that lists schedules.
Workspaces.schedules_fornarrows them, so one would be a thin adapter when a client needs it.
- MCP's
Options considered¶
| Option | Consistency across surfaces | Scope |
|---|---|---|
| All four over one command handler (chosen) | By construction | Largest |
| HTTP ingest and REST only | Trivial | Smallest; operators and agents get no live view or tools |
| Per-surface handlers | Drifts | Medium |
Refusing a workspace:
| Option | Assessment |
|---|---|
Workspaces.open(authorize=) raises Forbidden (chosen) |
One check and one message; each surface translates the rejection |
| Each surface asks the hook and words its own refusal | The same check twice, and two copies of the message |
Consequences¶
- Easier: a new surface is authentication plus translation into commands.
- Easier: one JSON Schema describes every frame, for clients in any language.
- Harder: four surfaces to test to 100% coverage, and a protocol to version.
Action items¶
- REST, the WebSocket, schedules and MCP over one command handler.
-
authorizeon every surface, throughWorkspaces.open. - Statuses computed by the workspace layer, and windows, type filters and tails of the log.