Skip to content
Open
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
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Packages without a separate changelog are covered by the cross-package notes below.

## [Unreleased]
## [Unreleased - Major]

### Added

- Registration caps for devices (nodes), agents and human members: a new registration past the deployment's limit is refused with HTTP 429 `plan_limit_exceeded` carrying `limit`, `max`, `current` and the host's context (`plan`, `upgrade_url` on the hosted gateway), on HTTP and on the node `agent.register` frame. Existing nodes and agents re-register freely.
- Self-hosted operators can cap their own instance with `config.limits`; absent keys stay unlimited.

### Breaking changes

- The engine no longer ships plan tiers: the `PLAN_LIMITS` table and every plan name/number are gone, `PlanLimits` is replaced by the metric-keyed `Limits` (`null` = unlimited), and all limits come from the deployment's entitlements provider. Self-hosted deployments are unlimited by default.

## [11.0.0] - 2026-10-22

Expand Down
46 changes: 30 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,31 +328,45 @@ deployments — where a control plane owns the canonical key — leave it unset
and rotate through that plane, so the route answers
`workspace_key_rotation_disabled`.

### Throttling
### Throttling and limits

Two different conditions return HTTP 429, and the error code tells you which:
Three conditions return HTTP 429, and the error code tells you which:

| Code | What it is | Retry |
| --- | --- | --- |
| `rate_limit_exceeded` | Per-minute request ceiling | Always clears inside the window; `Retry-After` is at most 60 |
| `plan_limit_exceeded` | Plan API-call quota for the current billing period | Clears only at the period boundary; `Retry-After` reports it |
| `plan_limit_exceeded` | API-call quota for the current usage period | Clears only at the period boundary; `Retry-After` reports it |
| `plan_limit_exceeded` with `limit` | Registration cap (`devices`, `agents` or `humans`) hit by a NEW node/agent registration; body carries `max`, `current` and the host's context (the hosted gateway adds `plan` and `upgrade_url`) | Clears when a registration is removed or the host raises the limit; no `Retry-After` |

Both carry `Retry-After` and `X-RateLimit-Reset`; successful responses carry
`X-RateLimit-Limit` and `X-RateLimit-Remaining`. `Retry-After` is authoritative
The first two carry `Retry-After` and `X-RateLimit-Reset`; when a finite per-minute
limit applies, successful responses carry `X-RateLimit-Limit` and
`X-RateLimit-Remaining` (an unlimited workspace — the self-hosted default — gets
neither). `Retry-After` is authoritative
— a value beyond your retry budget means retrying cannot help, so fail fast
rather than spending the budget on it.

A workspace's `plan` (returned by `GET /v1/workspace`) sets both limits:

| Plan | Agents | API calls / month | Requests / minute |
| --- | --- | --- | --- |
| `free` | 5 | 100,000 | 300 |
| `connect` | 10 | 10,000,000 | 1,200 |
| `pro` | 100 | 10,000,000 | 6,000 |
| `enterprise` | unlimited | unlimited | 30,000 |

`connect` is sized for short-lived collaboration rooms. Self-hosted deployments
default to `selfhost`, which is unlimited.
The engine carries no tiers or numbers of its own. Every limit comes from the
deployment's `EntitlementsProvider`, which maps a workspace to a `Limits`
object keyed by metric (`messages`, `agents`, `file_bytes`, `api_calls`,
`rate_per_min`, `devices`, `humans`, `message_retention_days`; `null` is
unlimited). A device is any node a host enrolls through `POST /v1/nodes`; the
implicit direct node every agent gets for its own WebSocket is not one.

- Self-hosted deployments are unlimited by default. To cap your own instance,
set static limits in the engine config; absent keys stay unlimited:

```ts
createNodeRuntime({
dbPath: './relaycast.db',
baseUrl: 'https://relay.example.com',
config: { limits: { devices: 5, agents: 15, humans: 2, rate_per_min: 600 } },
});
```

- A hosting product injects its own provider (`getLimits`, `getUsage`, optional
`getUsageResetAt` and `getLimitContext`). `getLimitContext` returns fields the
engine spreads verbatim onto the top level of a registration-cap 429 — on
`cast.agentrelay.com` that is `plan` and `upgrade_url`.

`GET /v1/workspace` is limited in its own bucket, so the identity read used for
credential validation and launch preflight stays reachable while the workspace's
Expand Down
75 changes: 73 additions & 2 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ info:
credential validation and launch preflight stays reachable when the
workspace's data-plane traffic is at its ceiling.

The deployment's entitlements provider may also cap how many devices
(nodes), agents and human members a workspace can register. Enrolling a NEW
node (`POST /nodes`) or registering a NEW agent (`POST /agents`,
`POST /a2a/register`) beyond the cap returns HTTP 429 `plan_limit_exceeded`
with the top-level fields `error_code`, `limit` (`devices`|`agents`|`humans`),
`max` and `current`, plus whatever context the provider supplies (the hosted
gateway adds `plan` and `upgrade_url`) so a client can explain the refusal
and offer an upgrade. An `agent.register` node-control frame at the cap is
answered on the WebSocket with an `error` frame carrying `code:
plan_limit_exceeded` and the same fields, not with an HTTP response.
Re-enrolling or rotating an existing node or agent is never capped. No
`Retry-After` is sent: the cap clears only when a registration is removed
or the provider raises the limit.

Workspace delivery capacity applies to all local message producers. A write
that would exceed it returns HTTP 429 with error code
`workspace_delivery_depth_exceeded` and `Retry-After: 30`. A required
Expand Down Expand Up @@ -182,8 +196,60 @@ components:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
PlanLimitExceeded:
description: >-
The workspace is at its registration cap for the requested kind.
Only a NEW registration is refused; re-enrolling or rotating an existing
node or agent never is. The cap clears when a registration is removed or
the provider raises the limit, so no `Retry-After` is sent.
content:
application/json:
schema:
$ref: '#/components/schemas/PlanLimitExceededResponse'
Comment thread
willwashburn marked this conversation as resolved.

schemas:
PlanLimitExceededResponse:
type: object
required: [ok, error, error_code, limit, max, current]
description: >-
Beyond the required fields, the deployment's entitlements provider may
add context at the top level (for example `plan` and `upgrade_url` on
the hosted gateway); the engine passes it through verbatim.
additionalProperties: true
properties:
ok:
type: boolean
enum: [false]
error:
type: object
required: [code, message]
properties:
code:
type: string
enum: [plan_limit_exceeded]
message:
type: string
error_code:
type: string
enum: [plan_limit_exceeded]
limit:
type: string
enum: [devices, agents, humans]
description: Which registration cap refused the request.
max:
type: integer
description: The cap for `limit`.
current:
type: integer
description: How many are registered now.
plan:
type: string
description: Provider-supplied, optional. The hosted gateway reports the workspace's billing tier here.
upgrade_url:
type: string
format: uri
description: Provider-supplied, optional. Where the workspace can raise its limits.

WorkspaceProvenanceInput:
type: object
description: >-
Expand Down Expand Up @@ -261,8 +327,7 @@ components:
description: Workspace API key (only returned on creation)
plan:
type: string
enum: [free, connect, pro, enterprise, selfhost]
description: Stored plan that sets the workspace's usage limits. `connect` is sized for short-lived collaboration rooms.
description: Host-assigned plan key stored on the workspace; opaque to the engine, which takes every limit from its entitlements provider.
created_at:
type: string
format: date-time
Expand Down Expand Up @@ -2495,6 +2560,8 @@ paths:
description: Malformed registration identity_key (invalid_agent_identity_key)
'409':
description: Agent name already exists in this workspace
'429':
$ref: '#/components/responses/PlanLimitExceeded'

get:
summary: List agents
Expand Down Expand Up @@ -5455,6 +5522,8 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'429':
$ref: '#/components/responses/PlanLimitExceeded'
get:
summary: List nodes
description: >
Expand Down Expand Up @@ -6774,6 +6843,8 @@ paths:
application/json:
schema:
type: object
'429':
$ref: '#/components/responses/PlanLimitExceeded'

/a2a/agents:
get:
Expand Down
16 changes: 15 additions & 1 deletion packages/engine/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [Unreleased]
## [Unreleased - Major]

### Added

- Registration caps are enforced at every path that creates a node or agent: `POST /v1/nodes`, `POST /v1/agents`, `POST /v1/a2a/register` and the `agent.register` node frame. A refusal is HTTP 429 `plan_limit_exceeded` with top-level `error_code`, `limit`, `max`, `current` and the provider's `getLimitContext` fields spread verbatim (the same fields on the WebSocket `error` frame). Re-enrolling or rotating an existing node/agent is never capped; an agent's implicit direct node is not a device, and the `node_direct_` id prefix is reserved for it (`reserved_node_id`).
- `Limits` (metric-keyed, `null` = unlimited) with `UNLIMITED_LIMITS` and `resolveLimits`; `UsageMetric` gains `devices`/`humans` and `EntitlementsProvider.getUsage` reports live registration counts for them (`countRegistrations` is exported for providers). Optional `EntitlementsProvider.getLimitContext` supplies the refusal's host context.
- `EngineConfig.limits` sets static limits for a self-hosted instance; `StaticEntitlementsProvider(kv, limits, db)` applies them and counts registrations from `db`.
- `workspaces.limit_overrides` (migration `0076`) is a host-owned JSON column the engine never interprets.
- `createRegistrationCapacityGuard` and `HandleNodeControlMessageArgs.capacity` let a host's node-control transport enforce the agent cap on `agent.register`.

### Breaking changes

- `PLAN_LIMITS` and every plan name, number and rank are removed from the engine; `PlanLimits` is replaced by `Limits`, whose metered fields are `number | null` (`null` = unlimited) instead of `number`/`Infinity`. `StaticEntitlementsProvider` no longer reads `workspace.plan`: it is unlimited unless `config.limits` is set. Hosts that relied on the built-in tiers must supply them from their own provider.
- `WorkspaceDeliveryPolicyConfig.resolve` receives `plan: string | null` (the row's host-assigned key, no `'free'` default).
- The `rate_limit_exceeded` and `plan_limit_exceeded` quota messages no longer name a plan.

## [11.0.0] - 2026-10-22

Expand Down
2 changes: 1 addition & 1 deletion packages/engine/scripts/public-consumer-compile.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const config = {
workspaceDelivery: {
resolve: async (workspace) => {
const id: string = workspace.id;
const plan: string = workspace.plan;
const plan: string | null = workspace.plan;
await Promise.resolve();
return id && plan === 'enterprise' ? { cap: 5000, reserve: 0 } : undefined;
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { eq } from 'drizzle-orm';
import { messages, observerTokens, workspaces } from '../../db/schema.js';
import { messages, observerTokens } from '../../db/schema.js';
import { dmFilteredCountConversationChunkSize, dmMessagePageLimit } from '../../engine/dmAll.js';
import { createWorkspace, makeNodeStack, registerAgent, type TestStack } from './harness.js';

Expand Down Expand Up @@ -55,10 +55,6 @@ describe('Relay Connect observer capability', () => {

it('lists more DM conversations than one D1 statement can bind, for the workspace and an observer', async () => {
const room = await createWorkspace(stack.app, 'connect-many-dms');
await stack.runtime.deps.db
.update(workspaces)
.set({ plan: 'enterprise' })
.where(eq(workspaces.id, room.workspaceId));
const alice = await registerAgent(stack.app, room.workspaceKey, 'alice');
const peers = Array.from({ length: 505 }, (_, index) => `peer-${String(index).padStart(3, '0')}`);
const conversationIds: string[] = [];
Expand Down
3 changes: 3 additions & 0 deletions packages/engine/src/__tests__/conformance/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export function makeNodeStack(options?: {
workspaceBootstrapProofRequired?: boolean;
allowWorkspaceKeyRotation?: boolean;
entitlements?: EntitlementsProvider;
/** Static limits for the default provider (`EngineConfig.limits`). */
limits?: EngineConfig['limits'];
}): TestStack {
const tasks = new BackgroundTasks();
observePresence();
Expand All @@ -79,6 +81,7 @@ export function makeNodeStack(options?: {
workspaceBootstrapSecret: options?.workspaceBootstrapSecret ?? 'test-bootstrap-secret',
workspaceBootstrapProofRequired: options?.workspaceBootstrapProofRequired,
allowWorkspaceKeyRotation: options?.allowWorkspaceKeyRotation,
limits: options?.limits,
},
entitlements: options?.entitlements,
// Disable the auto-sweep timer; tests drive presence.sweep() explicitly.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { makeNodeStack, createWorkspace, registerAgent, type TestStack } from './harness.js';
import { resolveLimits } from '../../ports/entitlements.js';

/**
* Regression for the captured cross-channel leak: a native MCP post result
Expand All @@ -13,9 +14,9 @@ import { makeNodeStack, createWorkspace, registerAgent, type TestStack } from '.
* non-member never seeing the mention.
*/
// Test-only entitlements: a high per-minute ceiling so the bounded >200-candidate
// fixture is not rate-limited by the free plan. No production limit changes.
// fixture exercises the limiter without being throttled.
const testEntitlements = {
getLimits: async () => ({ messages: 1_000_000, agents: 1_000_000, file_bytes: 1_000_000_000, api_calls: 1_000_000, rate_per_min: 100_000 }),
getLimits: async () => resolveLimits({ messages: 1_000_000, agents: 1_000_000, file_bytes: 1_000_000_000, api_calls: 1_000_000, rate_per_min: 100_000 }),
getUsage: async () => 0,
};

Expand Down
Loading
Loading