Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ are never participant-view fallbacks.

This table inventories the optional reference P2 HTTP app. `read` is the
existing `_ReadIdentity` role check, `mutate` is `_MutatingIdentity`, and
`resolve` is `_ResolutionIdentity`. All authenticated P2 identities require
`resolve` is `_ResolutionIdentity`. Issue #1359 renamed these dependencies to
`_AdministrativeReadIdentity`, `_AdministrativeMutationIdentity`, and
`_OperatorResolutionIdentity`, the `administrative-read`,
`administrative-mutation`, and `operator-resolution` transport authorities, with
unchanged role sets. All authenticated P2 identities require
the exact target binding. These checks authenticate the host's P2 caller;
they do not authenticate the host's participant-facing user. The host applies
its own caller and participant checks before releasing any result.
Expand Down
189 changes: 189 additions & 0 deletions docs/decisions/issue-1359-runtime-api-trust-boundary-preflight.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions docs/explain/sdl/runtime-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,15 @@ applies its own caller boundary. The accepted
[route and deployment boundary](../../decisions/issue-1356-control-plane-participant-access-preflight.md)
also covers operation readback, histories, errors, caches and events.

Every served P2 route declares exactly one transport authority: public probe,
administrative read, administrative mutation or operator resolution.
`create_control_plane_app()` refuses a route with none or several, and exposes
the `(method, path)` inventory as `app.state.control_plane_route_authority`.
Participant, audience, controller and operation-actor checks follow in the
core. Every response carries `Cache-Control: no-store`. The
[deployment guide](../../public/guides/control-plane.md) covers the host's
duties.

## Current Scope

The current runtime scope includes:
Expand Down
145 changes: 145 additions & 0 deletions docs/public/guides/control-plane.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Serve the runtime control plane

The optional P2 HTTP adapter serves one durable P1 control-plane core. It is an
administration and service interface for a trusted host application. It is not
a participant API.

[API-404](https://github.com/OpenRAE/rae/blob/main/docs/requirements/API-404/requirement.md)
owns the guarantees. The
[trust-boundary decision](https://github.com/OpenRAE/rae/blob/main/docs/decisions/issue-1356-control-plane-participant-access-preflight.md)
owns the route and release rules. Follow them if this guide seems to differ.

## Keep participants behind your host

Participant clients call your application, not RAES. Your host authenticates
its own caller. It selects the run, participant, exact episode, audience, and
operation that caller may use. It calls RAES with a private service identity.
It releases only an authorized, governed result.

Never give a browser, participant agent, plugin, or mobile client a P2 token or
proxy identity. Never give one the `RuntimeControlPlane` object or its store.
Possession of any of these is administrative access.

RAES does not authenticate your end users. An organizational host keeps its
users, groups, tenants, sessions, and policy. A local host applies its own
caller boundary. A P2 identity is not an SDL participant, controller, or role.

## Know what each route admits

Every served route declares exactly one transport authority. The adapter
refuses to start if a route declares none or more than one. Each authority also
serves only its own HTTP methods: `public-probe` and `administrative-read` serve
only `GET`, and the other two serve only `POST`, `PUT`, `PATCH`, or `DELETE`.

| Authority | Admitted roles | Routes |
| --- | --- | --- |
| `public-probe` | none | `GET /health/live`, `GET /health/ready` |
| `administrative-read` | backend, operator, auditor | `GET /snapshot`, `/apparatus/operational-summary`, `/operations/{operation_id}`, `/participant-executions/{execution_scope_ref}`, and participant `status`, `history`, and `context` views |
| `administrative-mutation` | backend, operator | Operation submission, workflow cancellation and timeouts, participant episode lifecycle, control occurrences, and participant execution control |
| `operator-resolution` | operator | `POST /operations/{operation_id}/resolution` |

Every authenticated route also requires an identity bound to this exact
target. FastAPI's `/openapi.json`, `/docs`, and `/redoc` describe the API. They
carry no runtime state.

The core applies further checks after admission:

- Operation readback requires the original actor and authorization scope.
Another actor gets the same `404` as an unknown operation.
- A control occurrence requires a matching participant/controller binding.
- With a crossing-policy resolver, a participant view requires exactly one
matching participant/audience binding. RAES commits the API-423 crossing
before it writes the view.

A read role admits the full snapshot, even when the identity also carries an
audience binding. An identity with bindings but no role is refused on every
route. There is no participant-limited P2 credential.

`app.state.control_plane_route_authority` maps each `(method, path)` to its
authority. Use it to build a proxy allowlist for your host's outbound calls.

## Configure service identities

```python
from raes_runtime import ControlPlaneProfile
from raes_runtime.control_plane_api import create_control_plane_app
from raes_runtime.control_plane_security import (
ControlPlaneIdentity,
ControlPlaneRole,
ControlPlaneSecurityConfig,
ParticipantAudienceSubjectBinding,
)

host_service = ControlPlaneIdentity(
identity="host-service",
roles=frozenset({ControlPlaneRole.BACKEND}),
target_name=control_plane.target_name,
participant_audience_subjects=(
ParticipantAudienceSubjectBinding("participant.alice", "audience:alice-console"),
),
)
security = ControlPlaneSecurityConfig(
bearer_tokens={secret_store.read("raes-host-token"): host_service},
)
app = create_control_plane_app(control_plane, security=security, profile=ControlPlaneProfile.P2)
```

Load each bearer token from your deployment's secret channel. Use a frozenset
for roles and tuples for bindings. The configuration refuses mistyped or
mutable authority fields. It also refuses an identity name or binding set that
exceeds the operation record limits: 256 characters per name or scope entry,
and 64 scope entries. A participant address in a binding cannot contain `:`.
It refuses a token or identity name with leading or trailing whitespace, so
strip a trailing newline from a secret file before you use it. The two trust
flags must be real `bool` values, and the size and queue limits must be `int`
values; parse strings from environment variables or YAML before you pass them.
An unknown bearer token fails with `401`. It never falls back to proxy headers.

Every transport-admission refusal returns the same body for its status: `401`
is always `unauthorized`, and `403` is always `forbidden`. The specific reason
goes only to the audit log. A malformed request body gets `422` only after the
caller is admitted.

Enable `trust_proxy_identity_headers` only behind a proxy that strips
client-supplied identity headers and sets verified ones.
`ControlPlaneSecurityConfig.strict_defaults()` trusts no token or header.

## Release participant views safely

- Configure a crossing-policy resolver before any participant-facing use.
Without one, `status`, `history`, and `context` return legacy administrative
projections. Do not release them to a participant.
- Check the returned participant, episode, and source state cut against your
selection before release.
- Reauthorize every release, including cached, retried, and replayed results.
A same-key replay returns the retained view to its original actor only.
- Never turn a snapshot, operation record, history, receipt, or error into a
participant view by filtering it.

## Deploy the adapter

- Serve P2 on an administration or service network. Participants must not
reach its socket.
- End TLS at your proxy. Authenticate service callers there as well.
- Run one worker with reload disabled. The adapter refuses a multi-worker
environment.
- Keep tokens out of URLs, process arguments, environment dumps, logs, crash
reports, fixtures, backups, and SDL.
- Every response carries `Cache-Control: no-store`. Do not configure proxy
caching for this API.
- Scope any host cache by caller or session, run, participant, episode,
audience, policy, and source revision. Invalidate it when any of them changes.
- Return your own participant-safe errors. Do not forward RAES errors, and do
not let `403` or `404` reveal whether another participant exists.
- Keep the store directory private. Treat store backups as privileged.

## Add a route to the adapter

Register the route inside `create_control_plane_app()`, before the adapter
checks its route inventory. Do not change the returned app. Its routes,
middleware, exception handlers, and dependency overrides are sealed at
construction; after any change, startup fails and every request gets a redacted
`500`. Declare one transport authority with the dependencies in
`raes_runtime.control_plane_api._auth`. A public probe is GET-only and must
return no runtime value. Then apply the operation's own subject policy in the
core. A stream, callback, or export must authorize every item it releases.
4 changes: 3 additions & 1 deletion docs/public/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ about five minutes and uses the Python package.
- **Controlling participant input or output?** Use the
[participant-control guide](participant-control.md).
- **Integrating RAES?** Choose the [Python API](guides/python.md) or
[command-line interface](guides/cli.md).
[command-line interface](guides/cli.md). To serve the runtime over HTTP, read
[serve the runtime control plane](guides/control-plane.md).
- **Building a backend?** Read the [backend and conformance guide](backends.md).
- **Evaluating the research?** Start with the [research context](research.md),
[current limits](limitations.md), and [citation](citation.md).
Expand All @@ -35,6 +36,7 @@ sdl/index
participant-control
guides/python
guides/cli
guides/control-plane
backends
research
limitations
Expand Down
25 changes: 24 additions & 1 deletion docs/requirements/API-404/requirement.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type: FUNCTIONAL
priority: MUST
wave: 1
created_at: 2026-04-03T05:55:58.825305Z
updated_at: 2026-09-23T00:00:00.000000Z
updated_at: 2026-09-26T00:00:00.000000Z
---

# API-404 — Secure, Durable, And Idempotent Control-Plane Semantics
Expand Down Expand Up @@ -67,6 +67,17 @@ selected P1 core. The corresponding runtime guarantee identifiers are
`owner-serialized-mutation`, and `revision-carrying-reads`. Only P2
authenticates transport callers; P0 and P1 rely on their trusted embedders.

Every served P2 route shall declare exactly one transport authority: a
value-free GET-only public probe, GET-only administrative read, or a
state-changing-method administrative mutation or operator resolution. P2
composition shall fail when any other route is registered, and a served app
shall refuse startup and every request when its routes, middleware, exception
handlers or dependency overrides differ from the composed set. Every
P2 response, including errors and idempotent readback, shall carry
`Cache-Control: no-store`. Transport-admission refusals shall not reveal why
admission failed, and no refusal shall reveal whether an operation outside the
caller's authority exists.

P2 identities are deployment-configured bearer tokens or verified proxy
identities, not RAES-issued participant credentials. A host may use one as a
service identity. Read-role admission to an API-408 participant projection
Expand Down Expand Up @@ -131,6 +142,18 @@ identifies those implementation gaps and the retained canonical requirements.

## Traceability

- DOCUMENTS → GITHUB_ISSUE `1359` (Enforce the accepted runtime API trust boundary)
- DOCUMENTS → DOCUMENTATION `docs/decisions/issue-1359-runtime-api-trust-boundary-preflight.md` (Administrative-only P2 enforcement guardrails and route authority matrix)
- IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_runtime/control_plane_security.py` (Route transport-authority roles and HTTP methods, unambiguous subject bindings, and fail-closed principal, credential, trust-flag and limit validation)
- IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_runtime/control_plane_api/_auth.py` (One declared method-bound transport authority per served route, fail-closed route inventory, sealed app composition, and non-revealing refusals)
- IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_runtime/control_plane_api/__init__.py` (Route-authority inventory and composition seal enforced at app construction and middleware-stack build, and exposed to the embedder)
- IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_runtime/control_plane_api_guards.py` (Application-wide `Cache-Control: no-store` boundary and per-request sealed-composition refusal)
- IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_runtime/control_plane_api/_operation_routes.py` (No-store and seal installation, uncacheable redacted 500 envelope, admission before request-validation errors, and resolution refusal indistinguishable from an unknown operation)
- IMPLEMENTS → CODE_FILE `implementations/python/packages/raes_runtime/control_plane_api_participant_retrieval.py` (Participant views admitted through the shared administrative-read authority)
- DOCUMENTS → DOCUMENTATION `docs/public/guides/control-plane.md` (P2 deployment guidance and host responsibilities)
- TESTS → TEST `implementations/python/tests/test_issue_1359_runtime_api_trust_boundary.py` (ASGI-boundary route inventory, method binding, composition seal, per-route admission, non-revealing refusals, governed and legacy views, replay scope, no-store and configuration checks)
- TESTS → TEST `implementations/python/tests/test_issue_1179_startup_reconciliation.py` (HTTP resolution refusal indistinguishable from an unknown operation)

- DOCUMENTS → GITHUB_ISSUE `1356` (Control-plane and participant-access trust boundary)
- DOCUMENTS → DOCUMENTATION `docs/decisions/issue-1356-control-plane-participant-access-preflight.md` (Accepted exposure model, route authority matrix, deployment and crossing guardrails)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
This package is a thin facade over cohesive route families:

* :mod:`._responses` - shared response-code declarations and the receipt builder.
* :mod:`._auth` - authentication, authorization, and identity dependencies.
* :mod:`._auth` - authentication, per-route transport authority, and identity
dependencies. App construction fails unless every served route declares one
authority; the resulting ``(method, path)`` inventory is exposed as
``app.state.control_plane_route_authority``.
* :mod:`._operation_routes` - request guards and operation submission/read routes.
* :mod:`._workflow_routes` - workflow cancellation and timeout reconciliation.
* :mod:`._participant_routes` - participant execution, control, and episode routes.
Expand All @@ -27,8 +30,10 @@

from fastapi import FastAPI
from starlette.concurrency import run_in_threadpool
from starlette.types import ASGIApp

from ..control_plane import RuntimeControlPlane
from ..control_plane_api_guards import refuse_unsealed_request
from ..control_plane_api_participant_retrieval import register_participant_retrieval_routes
from ..control_plane_profiles import (
ControlPlaneCapability,
Expand All @@ -38,7 +43,12 @@
)
from ..control_plane_security import ControlPlaneSecurityConfig
from ..control_plane_store_lease import require_single_worker_configuration
from ._auth import _ControlPlaneApiAuth
from ._auth import (
_ControlPlaneApiAuth,
_require_route_transport_authority,
_require_sealed_app_composition,
_seal_app_composition,
)
from ._health_routes import _register_health_routes
from ._offload import _ControlPlaneCallExecutor
from ._operation_routes import _install_request_guards, _register_operation_routes
Expand Down Expand Up @@ -76,6 +86,20 @@ def _control_plane_api_version() -> str:
return "0.0.0+unknown"


class _ControlPlaneFastAPI(FastAPI):
"""FastAPI app that serves only the composition checked at construction."""

def build_middleware_stack(self) -> ASGIApp:
# Middleware and exception handlers are fixed when the stack is built, so
# this is the last point at which a post-construction addition can be
# refused; the stack then refuses lifespan startup and every request.
try:
_require_sealed_app_composition(self)
except RuntimeError:
return refuse_unsealed_request
return super().build_middleware_stack()


def create_control_plane_app(
control_plane: RuntimeControlPlane,
*,
Expand All @@ -100,14 +124,15 @@ def create_control_plane_app(
executor = _ControlPlaneCallExecutor(max_pending_mutations=security.max_pending_mutations)

@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
async def lifespan(served: FastAPI) -> AsyncIterator[None]:
try:
_require_sealed_app_composition(served)
yield
finally:
await executor.close()
await run_in_threadpool(control_plane.close)

app = FastAPI(
app = _ControlPlaneFastAPI(
title="RAES Runtime Control Plane",
version=_control_plane_api_version(),
description="Reference HTTP/JSON adapter over the repo-owned runtime control plane.",
Expand All @@ -124,4 +149,6 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
_register_participant_control_routes(app, control_plane)
_register_participant_execution_routes(app, control_plane)
register_participant_retrieval_routes(app, control_plane)
app.state.control_plane_route_authority = _require_route_transport_authority(app)
_seal_app_composition(app)
return app
Loading
Loading