Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
# Changelog

## Unreleased

- Add `SolidObjects::Web`, a mountable Rack dashboard for the actor runtime.
It covers instances and their committed state, the ready and claimed
mailbox, reminders, effects, broadcasts, dead letters, and processes, with
actor-type and actor-id filtering, status filters, paging, and a polled
`GET /stats` endpoint. Mount it with
`mount SolidObjects::Web => "/solid_objects/dashboard"` after
`require "solid_objects/web"`; requiring the gem does not load it, so a
worker process carries no web stack.
- Authorize every dashboard route through `authorize_administration`. Each
route declares its own `action` and `resource`, and a route declared without
a policy raises at load time. The policy receives a context that answers
`request`, `session`, and `env`.
- Add two dashboard actions: an idempotent dead letter retry through
`SolidObjects.dead_letters.retry`, and instance pause/resume, which sets and
clears `paused_at` so the activation manager stops claiming that identity. A
retry the mailbox refuses, such as an actor class that no longer exists,
renders the reason with a 422 rather than failing the request.
- Draw instances per actor type, mailbox depth, and outbox and reminder status
with Chart.js, loaded from a CDN with a subresource integrity hash. The CDN
host is the only external origin the content security policy names. Point
`SolidObjects::Web.chart_library_url` at a vendored copy for a deployment
with no outbound network access, or set it to nil to render without charts.
- Add `SolidObjects::Web.register` for extension tabs, routes, and view
directories, and `SolidObjects::Web.use` for Rack middleware in front of the
dashboard.
- Add `rack` as an explicit dependency at `>= 3.1`, and package the `web/`
directory in the gem.

## 0.13.0 - 2026-08-13

- **Breaking:** make observables invalidation-only by default. An ordinary
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ PATH
actionview (>= 8.0)
activerecord (>= 8.0)
activesupport (>= 8.0)
rack (>= 3.1)
railties (>= 8.0)
thor (>= 1.3)

Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ tested, but the project does not yet claim production readiness. See
- [State migrations](#state-migrations)
- [Configuration](#configuration)
- [Workers and operations](#workers-and-operations)
- [Dashboard](#dashboard)
- [Database support](#database-support)
- [Guarantees](#guarantees)
- [When to use it](#when-to-use-it)
Expand Down Expand Up @@ -1084,6 +1085,50 @@ an initializer.
See the [operations guide](docs/operations.md) for monitoring, reconciliation,
shutdown, retention, and backup guidance.

## Dashboard

`SolidObjects::Web` is a Rack application that shows instances and their state,
the mailbox, reminders, effects, broadcasts, dead letters, and the registered
processes. Mount it inside the application routes, so the Rails session
middleware runs first:

```ruby
# config/routes.rb
require "solid_objects/web"

Rails.application.routes.draw do
mount SolidObjects::Web => "/solid_objects/dashboard"
end
```

It is not loaded by `require "solid_objects"`: a worker process must not carry
a web stack. The dashboard and the engine are separate mounts, so an
application that uses reactive ERB mounts both on different paths.

Every page asks `authorize_administration` before its handler runs, and that
policy denies by default, so a mount alone exposes nothing. The block receives
the route's own `action:` and `resource:`, and an `authorization_context:` that
answers `request`, `session`, and `env`.

The dashboard changes only two things. Retrying a dead letter goes through
`SolidObjects.dead_letters.retry`, which is idempotent. Pausing an instance
sets `paused_at` so the activation manager stops claiming that identity; a pass
already in flight finishes its turn, and a synchronous caller waiting on a
paused instance times out rather than receiving a result.

The dashboard draws instances per actor type, mailbox depth, and outbox status
with Chart.js, loaded from a CDN with a subresource integrity hash. A
deployment with no outbound network access can vendor the file, or turn the
charts off:

```ruby
SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js"
SolidObjects::Web.chart_library_integrity = nil
```

Read the [dashboard guide](docs/dashboard.md) for the full policy table,
extension registration, and query cost.

## Database support

Solid Objects supports:
Expand Down
9 changes: 8 additions & 1 deletion docs/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ intentionally inert until the host application defines its trust boundary.
| `authorize_query` | Attribute reads, declared queries, committed snapshots, scalar observable reads, initial component rendering, and every component refresh dependency | Explicit call context, the context passed to `solid_object`, or the request context resolved for a component refresh | Actor state or personalized projections can leak across users or tenants |
| `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted |
| `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors |
| `authorize_administration` | Engine administration controllers, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
| `authorize_administration` | Engine administration controllers, every `SolidObjects::Web` page, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller, a `SolidObjects::Web` request that answers `request`/`session`/`env`, or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |

Waiting again through `MessageReference#wait` reauthorizes the stored
invocation as a message or query. Internal reminder, effect-callback, and
Expand Down Expand Up @@ -154,6 +154,13 @@ configuration.authorize_administration = lambda do |authorization_context:, **|
end
```

That policy also denies every `SolidObjects::Web` page, which is the correct
result for a host whose only administration boundary is shell access. A policy
that opens the dashboard should separate reading from writing, because
`action` distinguishes them: `index` and `show` read, while `pause`, `resume`,
and `retry` change the runtime. The [dashboard guide](dashboard.md) lists the
action and resource of every page.

Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy
probe is deliberately conservative: a context-aware policy may correctly warn
because it denies a `nil` context.
200 changes: 200 additions & 0 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# Operator dashboard

`SolidObjects::Web` is a Rack application that shows what the actor runtime is
doing: instances and their state, the mailbox, reminders, effects, broadcasts,
dead letters, and the registered processes. It reads the same tables the
runtime writes, so it needs no separate store and no agent.

It is deliberately not loaded by `require "solid_objects"`. A worker process
must not carry a web stack, and an application that never mounts the dashboard
must not pay for it.

## Mounting

```ruby
# config/routes.rb
require "solid_objects/web"

Rails.application.routes.draw do
mount SolidObjects::Web => "/solid_objects/dashboard"
end
```

Mount it inside the application routes so the Rails session middleware runs
first. The dashboard needs a Rack session for CSRF protection and refuses a
state changing request without one.

The dashboard and the engine are separate mounts. Mount the engine as well if
the application uses reactive ERB, and give each one its own path:

```ruby
mount SolidObjects::Engine => "/solid_objects"
mount SolidObjects::Web => "/solid_objects/dashboard"
```

In a bare Rack application, supply the session middleware yourself:

```ruby
use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true
run SolidObjects::Web
```

## Authorization

Every page asks `configuration.authorize_administration` before its handler
runs. That policy denies by default, so a mount alone exposes nothing. A route
declared without a policy raises at load time, which is why a new page cannot
reach the actor tables before an application has said who may read it.

The block receives the route's own action and resource:

| Page | `action` | `resource` | `resource_id` |
| --- | --- | --- | --- |
| Dashboard, `GET /stats`, `HEAD /` | `index` | `dashboard` | none |
| Instance list | `index` | `instances` | none |
| Instance detail | `show` | `instances` | instance id |
| Pause an instance | `pause` | `instances` | instance id |
| Resume an instance | `resume` | `instances` | instance id |
| Mailbox | `index` | `messages` | none |
| Message detail | `show` | `messages` | message id |
| Reminders | `index` | `reminders` | none |
| Effects | `index` | `effects` | none |
| Broadcasts | `index` | `broadcasts` | none |
| Dead letter list | `index` | `dead_letters` | none |
| Dead letter detail | `show` | `dead_letters` | dead letter id |
| Retry a dead letter | `retry` | `dead_letters` | dead letter id |
| Processes | `index` | `processes` | none |

`authorization_context:` is the request object. It answers `request`,
`session`, and `env`, so a policy can read the signed-in operator the same way
a controller does:

```ruby
SolidObjects.configure do |configuration|
configuration.authorize_administration = lambda do |action:, authorization_context:, **|
return false unless authorization_context.respond_to?(:session)

operator = Operator.find_by(id: authorization_context.session[:operator_id])
return false unless operator&.administrator?

action == "index" || action == "show" || operator.may_write_runtime?
end
end
```

The command line reaches the same policy with `{ source: "cli" }` rather than
a request, which is why the example checks what the context answers before
reading a session from it.

## Pages

**Dashboard.** Totals per subsystem, the registered processes, and the most
recent dead letters. The summary bar appears on every page and can poll
`GET /stats` for the same numbers; nothing else on the page refreshes, because
a table that reloads under an operator who is reading it is worse than a stale
one.

**Instances.** Filter by actor type and by an actor id substring. Each row
shows the lease state: `idle`, `activated`, `expired`, or `paused`. The detail
page shows committed state, the ready and claimed mailbox, message history,
reminders, effects, broadcasts, and dead letters for that identity.

**Mailbox.** The ready and claimed messages across every identity, oldest
first. Mailbox lag on the summary bar is the age of the oldest message that is
already due, which is how far behind the workers are.

**Reminders, effects, broadcasts, processes.** Status filtered lists.

## Charts

The dashboard draws three charts: instances per actor type, mailbox depth, and
a stacked view of effects, broadcasts, and reminders by status. Each canvas
carries its own numbers in a `data-chart-values` attribute, so the page needs
no inline script and no request to draw. Mailbox depth and the status chart
redraw when the Live poller reports new totals, because `/stats` already
carries those numbers. The instance chart does not: `/stats` does not group by
actor type, and adding that would put a `GROUP BY` on every poll.

Chart.js comes from a CDN with a subresource integrity hash, so a compromised
CDN cannot substitute other code, and the CDN host is the only external origin
the content security policy names.

A deployment with no outbound network access should vendor the file:

```ruby
SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js"
SolidObjects::Web.chart_library_integrity = nil
```

A path below the mount is served from the dashboard's own asset directory and
needs no policy exception. Setting the URL to `nil` renders the dashboard
without charts and names no external origin at all.

Set these before the first request. The middleware stack and the compiled
templates are built once and cached.

**Dead letters.** The exception, its message, and its backtrace, with a retry
button.

## Actions

The dashboard changes only two things.

**Retry a dead letter** goes through `SolidObjects.dead_letters.retry`, which
enqueues the original operation under an idempotency key. Pressing it twice
produces one message rather than two.

A retry re-enters the mailbox, which refuses work the runtime cannot accept: an
actor class that no longer exists, a full mailbox, a payload over the cap. The
dashboard renders the dead letter again with the reason and a 422 status,
rather than failing the request.

**Pause an instance** sets `paused_at`, and the activation manager stops
claiming that identity. Two consequences matter:

- A pass already in flight finishes its turn. Pause is not a stop.
- A synchronous caller waiting on a paused instance times out rather than
receiving a result, because nothing will execute its message.

Resume clears the column and the mailbox drains in sequence order.

## Extensions

An extension adds pages by declaring routes on the application class. Its
routes carry an authorization policy like every other route:

```ruby
module Tenants
def self.registered(application)
application.get "/tenants", policy: { action: "index", resource: "tenants" } do
@tenants = Tenant.order(:name)
erb(:tenants)
end
end
end

SolidObjects::Web.register(
Tenants,
tab: "Tenants",
path: "/tenants",
views: File.expand_path("../web/views", __dir__)
)
```

A registered view directory is searched before the packaged one, so an
application can replace a single page without forking the gem. A template
reads its arguments from `locals`, and a replacement `layout.erb` renders the
page it wraps with `locals.fetch(:content)`.

Add Rack middleware in front of the dashboard with `SolidObjects::Web.use`,
for example to require HTTP basic authentication in an environment that has no
session-backed operator.

## Cost

The summary bar issues one grouped count per subsystem on every page, and each
list page counts its own relation to page it. That is a fixed set of indexed
aggregate queries, not a scan proportional to actor traffic, but it is not
free: do not put the dashboard behind an uptime monitor that loads the whole
page on an interval. `HEAD /` exists for that. It touches one table and
returns no body.
21 changes: 19 additions & 2 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,25 @@
handing the block a raw Cable connection.
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
distributed per-actor rate limits and global admission control do not.
- Administration: actor and dead-letter views plus policy hooks exist; richer
filtering, audit records, and bulk-safe tools do not.
- Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
instances, mailbox, reminders, effects, broadcasts, dead letters, and
processes, with actor-type and actor-id filtering, status filters, paging, a
polled stats endpoint, Chart.js charts, and extension registration. The chart
library is fetched from a CDN with a subresource integrity hash, which a
deployment without outbound network access must replace with a vendored copy
or turn off. Every route declares its
own administration policy and a route declared without one raises at load
time, so the deny-by-default posture is enforced by construction rather than
by remembering to add a check. It changes only two things: an idempotent dead
letter retry and instance pause/resume. What does not exist is audit records
of who pressed what, and bulk-safe tools: retry is one dead letter at a time,
because `DeadLetterManager` exposes no bulk operation. Pause is an operator
brake and not a stop, since a pass already in flight finishes its turn and a
synchronous caller waiting on a paused instance times out. The page cost was
reasoned about rather than measured: the summary bar issues a fixed set of
indexed aggregate queries per page, which is why `HEAD /` exists for uptime
monitors, but no dashboard latency has been benchmarked against a large
table.

## Next milestones

Expand Down
Loading
Loading