RFC: Content-pack-aware admin panel¶
Document version: 0.2
Date: July 10, 2026
Status: Draft
Authors: MAID Development Team
Summary¶
The MAID admin panel should describe and render the administration surfaces that the running server actually supports. Engine-owned pages should remain available independently of game content. Content-pack pages should appear only when their owning pack is loaded and their backing services are usable. Design-only tools should identify themselves as design tools rather than presenting browser-local or synthetic data as live game state.
This RFC proposes:
- An engine-owned registry of admin surfaces.
- Separate, declarative contracts for client routes, navigation, editor tabs, toolbar actions, and widgets.
- A role-filtered
GET /admin/ui/manifestendpoint. - Data-driven navigation and route guards in the React admin frontend.
- Explicit live, design-only, hybrid, unavailable, degraded, forbidden, and restart-required states.
- A staged runtime policy that updates the UI immediately while treating FastAPI route mutation as a separate, later concern.
The proposal does not allow content packs to ship arbitrary frontend JavaScript. Packs reference audited views already included in the MAID admin bundle or generic views parameterized by validated data.
Motivation¶
The current admin frontend presents a mostly fixed product even though the server is assembled from content packs.
packages/maid-engine/admin_frontend/src/components/Layout.tsxhardcodes sidebar navigation.packages/maid-engine/admin_frontend/src/App.tsxhardcodes React routes.packages/maid-engine/src/maid_engine/api/admin/router.pymounts engine admin routers and then callsregister_api_routes()on packs loaded when the FastAPI app is created.packages/maid-stdlib/src/maid_stdlib/pack.pycontributes the entity, player, world, and memory APIs.packages/maid-engine/src/maid_engine/api/admin/packs.pyreports loaded and available packs, but not the admin surfaces they provide.packages/maid-engine/src/maid_engine/api/admin/extensions.pydefines an editor-extension response, but the normal application does not populate it.
This creates several misleading states:
- A navigation item can be visible while its backend route is absent because the owning pack was not loaded.
- A loaded pack can provide components, systems, or APIs without the admin UI exposing or attributing them.
- Runtime pack loading and unloading can change
engine.content_packswithout changing the FastAPI route table created at startup. - Quest and dialogue editors store design data in browser
localStorageand export YAML, while other screens edit live state. - Balance views can fall back to reference data, making it difficult to tell whether an operator is looking at the running world or an example curve.
The observability dashboard feels more complete because it is engine-owned and does not depend on a content pack. The rest of the panel needs an equally clear ownership and availability model.
Goals¶
- Make the admin panel an accurate description of the running server.
- Attribute each admin surface to engine core or a loaded content pack.
- Keep navigation, route availability, permissions, and backing state consistent.
- Give content-pack authors a typed, declarative extension mechanism.
- Support useful generic administration without requiring custom React code.
- Reflect runtime load, unload, and reload operations honestly.
- Fail closed and visibly when a contribution is invalid or unavailable.
- Preserve endpoint authorization as the security boundary.
Non-goals¶
- Loading arbitrary React bundles, module-federation remotes, or scripts from content packs.
- Replacing the existing content-pack lifecycle API.
- Making every pack provide a custom page.
- Inferring user-facing navigation from OpenAPI or route names.
- Removing or replacing FastAPI routes in a running process in the first implementation.
- Turning hybrid quest and dialogue authoring tools into runtime quest systems.
Design principles¶
Declare intent instead of inferring it¶
Routes describe transport, not user experience. They do not describe labels, icons, grouping, backing state, React views, or whether several endpoints form one administration workflow. Route and OpenAPI introspection should verify declared surfaces, not define them.
Separate display from authorization¶
The manifest may hide surfaces from users below a display role, but backend
dependencies such as require_role() remain the authorization boundary. A
visible surface never grants access to its endpoints.
Use one fixed, audited frontend bundle¶
Content packs contribute data that references known frontend capabilities. They do not contribute executable browser code. Unknown view identifiers are rejected or marked unavailable.
Prefer explicit degradation over plausible output¶
The UI must not silently substitute examples for live data. Empty live data, design-only state, missing dependencies, and backend failures are different states and must appear differently. Balance reference data is not part of the admin contract.
Treat runtime routes as a separate lifecycle¶
Changing registry data is safe and cheap. Mutating Starlette's live route list is order-sensitive, affects cached OpenAPI state, and has no supported atomic removal operation. The first implementation should not conflate these tasks.
Terminology and states¶
Pack state¶
Pack state has separate dimensions. The UI must not describe a bad admin descriptor as degraded game runtime health.
| Field | Values | Meaning |
|---|---|---|
availability |
available, unavailable |
Whether discovery can find the pack. |
load_state |
unloaded, loading, active, disabling, failed |
Committed runtime lifecycle state. |
admin_ui_status |
enabled, degraded, none |
Whether declared admin contributions are usable. |
engine.content_packs membership is not sufficient to determine load_state.
Runtime lifecycle operations currently expose partially completed states at
different points. The runtime phase of this RFC therefore requires committed
lifecycle events from one serialized coordinator before the admin registry or
route guards change state.
Surface backing¶
| State | Meaning | UI behavior |
|---|---|---|
live |
Reads and writes use current engine or persistence state. | Render normally with a Live label where ambiguity exists. |
design |
State is browser-local or export-oriented and does not alter the live world. | Show a persistent Design only badge and page banner. |
hybrid |
The view combines browser-local drafts with live entity or server operations. | Identify local and live actions separately, including their required roles. |
Backing and availability are separate. Every route also has one status:
enabled, forbidden, unavailable, degraded, or restart_required.
Surface taxonomy¶
Engine surfaces¶
Engine surfaces exist independently of game content. Initial examples include:
- Dashboard
- Logs
- Configuration
- Content packs and pack inventory
- Maintenance
- AI player operations
The engine registers these through the same registry used by packs so the frontend has one rendering path.
Generic pack-aware surfaces¶
Generic surfaces use stable engine or stdlib APIs and can represent many packs without custom frontend code. Examples include:
- Entity and component browser
- World and room browser
- Player administration
- Generic component table
- Generic read-only REST panel
- Pack inventory showing systems, components, capabilities, routes, and contribution errors by owner
The entity browser should expose pack ownership for registered component types and allow filtering by pack. The system should not create one navigation item for every component automatically.
Declared pack surfaces¶
A pack may explicitly contribute navigation, editor tabs, toolbar actions, or dashboard widgets. Each contribution references a frontend view already known to the bundled client.
Examples include:
stdlib.entitiesusing the bundled entity browser.stdlib.worldusing the bundled world browser.core.balanceusing the bundled balance dashboard and registered providers.classic-rpg.quest-designerusing a bundled hybrid editor.- A tutorial pack help page using a sanitized documentation view.
Admin contribution contract¶
Add neutral, engine-owned models that do not import FastAPI or the content-pack protocol. Contract version 1 uses discriminated models rather than one navigation-shaped structure for every contribution:
from __future__ import annotations
from enum import StrEnum
from typing import Annotated, Literal
from pydantic import BaseModel, Field
class AdminBackingKind(StrEnum):
LIVE = "live"
DESIGN = "design"
HYBRID = "hybrid"
class RequiredHttpRoute(BaseModel):
method: str
path_template: str
class AdminAction(BaseModel):
id: str
min_role: str
class AdminClientRoute(BaseModel):
kind: Literal["route"] = "route"
id: str
path_pattern: str
view_id: str
view_params: dict[str, object] = Field(default_factory=dict)
min_view_role: str = "VIEWER"
backing: AdminBackingKind
required_http_routes: list[RequiredHttpRoute] = Field(default_factory=list)
actions: list[AdminAction] = Field(default_factory=list)
class AdminNavigationItem(BaseModel):
kind: Literal["navigation"] = "navigation"
id: str
route_id: str
label: str
section: str = "content"
icon: str | None = None
order: int = 100
class AdminInspectorTab(BaseModel):
kind: Literal["inspector_tab"] = "inspector_tab"
id: str
label: str
view_id: str
target_types: list[str] = Field(default_factory=list)
min_role: str = "VIEWER"
class AdminToolbarItem(BaseModel):
kind: Literal["toolbar_item"] = "toolbar_item"
id: str
label: str
action_id: str
min_role: str
icon: str | None = None
class AdminExternalLink(BaseModel):
kind: Literal["external_link"] = "external_link"
id: str
label: str
url: str
section: str = "content"
order: int = 100
min_role: str = "VIEWER"
AdminContribution = Annotated[
AdminClientRoute
| AdminNavigationItem
| AdminInspectorTab
| AdminToolbarItem
| AdminExternalLink,
Field(discriminator="kind"),
]
Dashboard widgets can be added as another discriminated model after navigation, route, and editor contracts have shipped. They are not part of contract version 1.
Do not add a required method to the runtime-checkable ContentPack protocol.
That would break structural pack implementations that do not inherit from
BaseContentPack. Define a separate optional capability:
from typing import Protocol, runtime_checkable
@runtime_checkable
class AdminSurfaceProvider(Protocol):
def get_admin_contributions(self) -> list[AdminContribution]:
"""Return declarative admin contributions supplied by this pack."""
...
BaseContentPack may provide a convenience implementation that returns [].
Collection uses isinstance(pack, AdminSurfaceProvider) or a guarded optional
hook. Legacy structural and YAML-only packs contribute no custom surfaces but
still appear in Pack Inventory.
Ownership comes from registration context, never from pack-supplied data. Pack
identifiers must begin with {pack.manifest.name}.; core. and other reserved
engine prefixes are forbidden. Namespace mismatches and reserved prefixes are
rejected before collision resolution.
min_role and action roles parse through AdminRole.from_string() and accept
only VIEWER, MODERATOR, BUILDER, ADMIN, or SUPERADMIN. Invalid values
fail closed.
View IDs, action IDs, icons, sections, and view_params come from a versioned
engine catalog. Each view has a typed parameter schema. Unknown keys are
dropped and invalid values reject the descriptor. Generic REST views may call
only validated, same-origin, read-only routes under /admin/ owned by the
contributing pack.
Initial generic parameter schemas include:
generic.component-table:component_type, an allowlisted column projection, and optional owner/tag filters.generic.rest-panel: an ownedGETmethod/path pair, a response shape (key_valueortable), and allowlisted field projections.
SPA paths are relative to the /admin-ui basename. Validation rejects schemes,
protocol-relative paths, backslashes, control characters, traversal, and
encoded separator bypasses. External documentation links are a separate
contribution type, allow only approved https origins, and render with
rel="noopener noreferrer".
Frontend view registry¶
The engine owns a versioned catalog of allowed view IDs, action IDs, parameter schemas, icons, and sections. The TypeScript catalog is generated from or validated against the same source in CI. This allows the backend to reject an unknown view before serving it while the frontend maps known views to audited components:
export const adminViews = {
"core.dashboard": DashboardPage,
"core.logs": LogsPage,
"core.config": ConfigPage,
"core.pack-inventory": PackInventoryPage,
"stdlib.entities": EntitiesPage,
"stdlib.players": PlayersPage,
"stdlib.world": WorldPage,
"stdlib.map": MapEditorPage,
"generic.component-table": GenericComponentTable,
"generic.rest-panel": GenericRestPanel,
"design.quest": QuestEditorPage,
"design.dialogue": DialogueEditorPage,
"core.balance": BalanceDashboardPage,
} satisfies Record<string, React.ComponentType<AdminSurfaceProps>>;
The route catalog includes every current client route, including parameterized detail routes. Navigation items reference route IDs rather than duplicating paths.
| Route ID | Pattern | Owner/prerequisite | Backing |
|---|---|---|---|
core.dashboard |
/ |
engine | live |
stdlib.map |
/map |
stdlib world routes | live |
stdlib.entities |
/entities |
stdlib entity routes | live |
stdlib.players |
/players |
stdlib player routes | live |
stdlib.world |
/world |
stdlib world routes | live |
stdlib.content |
/browse |
stdlib entity routes | live |
stdlib.bulk |
/bulk |
stdlib entity and world routes | live |
stdlib.yaml |
/yaml |
stdlib entity and world routes | hybrid |
core.play-mode |
/play-mode |
browser-only mock terminal and local recordings | design |
stdlib.npc-detail |
/npcs/:id |
stdlib entity API and stdlib NPC components | live |
stdlib.item-detail |
/items/:id |
stdlib entity API and base item components; Classic RPG sections are capability-gated | live |
classic-rpg.quest-detail |
/quests/:id |
stdlib entity API plus local draft storage | hybrid |
classic-rpg.dialogue-detail |
/dialogues/:id |
stdlib entity API plus local draft storage | hybrid |
core.balance |
/balance |
engine live content density or registered live providers | live, empty, unavailable, or failure per widget |
core.logs |
/logs |
engine | live |
core.config |
/config |
engine | live |
core.pack-inventory |
/packs |
engine | live |
The final implementation inventory must also record which routes appear in navigation. Detail routes do not.
The registry may contain whitelisted actions for toolbar and page operations.
It must not evaluate code, render unsanitized markup, accept javascript:
URLs, or load remote scripts. Pack-supplied labels, descriptions, sections,
diagnostics, and documentation render as text or through an audited sanitizer,
never raw dangerouslySetInnerHTML.
Unknown views do not crash the frontend. The surface is omitted from normal navigation and appears as a contribution error in Pack Inventory.
Authoritative admin surface registry¶
Introduce an AdminUIService on app.state. It owns the registry, immutable
manifest snapshots, and notifier. It subscribes to committed engine lifecycle
events during the FastAPI lifespan; the app-owned registry is not stored on the
engine.
Responsibilities:
- Seed engine-owned surfaces.
- Add validated routes and contributions for each active pack generation.
- Remove all contributions for one pack.
- Reject identifier, SPA path, and HTTP method/path collisions deterministically.
- Record contribution errors without preventing the pack from loading.
- Track the owner and generation of routes mounted at startup.
- Filter by role.
- Produce an immutable snapshot, revision number, and response-specific ETag.
- Supply editor extensions to the existing
/admin/editor/extensionsendpoint.
Duplicate surface identifiers are registration errors. Engine identifiers cannot be overridden. Pack errors should degrade only the offending contribution; they should not blank the panel or prevent unrelated pack systems from starting.
The existing editor-extension endpoint is projected from discriminated contributions without changing its response shape:
- Owner name becomes
pack_name. - Inspector
id,label, andtarget_typesbecomeEditorTab. - Toolbar
id,label,icon, andaction_idbecomeEditorToolbarItem.action. - Contributions are grouped by owner into
EditorExtension.
UI manifest API¶
Add:
The endpoint requires at least VIEWER, filters navigation and action
entitlements to the current role, and returns an ETag with:
Navigation is role-filtered. Client-route records are returned separately so
deep links can distinguish forbidden, unavailable, restart-required, and
unknown routes. A forbidden route record contains only its ID, path pattern,
and forbidden status; it does not expose labels, pack diagnostics, endpoint
requirements, or actions.
Representative response:
{
"contract_version": 1,
"revision": 7,
"generated_at": "2026-07-10T21:00:00Z",
"packs": [
{
"name": "stdlib",
"display_name": "MAID Standard Library",
"version": "0.1.0",
"availability": "available",
"load_state": "active",
"admin_ui_status": "enabled",
"provides": ["basic-movement", "basic-inventory"],
"contribution_errors": []
},
{
"name": "classic-rpg",
"display_name": "Classic RPG",
"version": "0.1.0",
"availability": "available",
"load_state": "active",
"admin_ui_status": "enabled",
"provides": ["combat-system", "quest-system", "economy-system"],
"contribution_errors": []
}
],
"routes": [
{
"id": "core.dashboard",
"owner": {"kind": "engine"},
"path_pattern": "/",
"view_id": "core.dashboard",
"backing": "live",
"status": "enabled",
"actions": []
},
{
"id": "classic-rpg.quest-detail",
"owner": {
"kind": "pack",
"name": "classic-rpg",
"version": "0.1.0"
},
"path_pattern": "/quests/:id",
"view_id": "design.quest",
"backing": "hybrid",
"status": "enabled",
"actions": [
{"id": "save-local-draft", "min_role": "BUILDER"},
{"id": "delete-live-entity", "min_role": "ADMIN"}
]
},
{
"id": "core.config",
"path_pattern": "/config",
"status": "forbidden"
}
],
"navigation": [
{
"id": "core.dashboard-nav",
"route_id": "core.dashboard",
"label": "Dashboard",
"section": "overview",
"icon": "home",
"order": 0
},
{
"id": "stdlib.entities-nav",
"route_id": "stdlib.entities",
"label": "Entities",
"section": "content",
"icon": "cube",
"order": 20
}
]
}
The manifest describes routes, navigation, backing state, and current-caller
action entitlements. /admin/packs remains the pack lifecycle and management
API.
Administrators may request diagnostics:
This requires ADMIN and includes omitted surfaces, missing requirements,
collisions, and contribution errors.
Public contribution errors use fixed codes and redacted messages. Raw
exceptions, filesystem paths, validation internals, and reflected unsafe values
remain in server logs or the ADMIN diagnostics variant.
generated_at is the time the immutable snapshot was committed, not request
time. The ETag is derived from the complete rendered variant, including
contract version, effective role, diagnostics flag, route and backing statuses,
action entitlements, and contribution errors. Every status transition creates
a new snapshot. Authorization runs before conditional request handling.
Startup registration¶
Normal CLI startup already discovers and loads content packs before the web server constructs its FastAPI app. The startup sequence should:
- Create the registry.
- Register engine surfaces.
- Iterate loaded packs in engine load order.
- Call each pack's existing
register_api_routes()against an isolated scratchAPIRouter. - If route registration succeeds, validate all method/path pairs, reserved paths, duplicates, response models, and collisions before including the scratch router atomically.
- Attach an explicit
VIEWERauthorization baseline to every pack route, including routes whose pack omitted authentication. Preserve and compose stronger pack-providedrequire_role()orrequire_permission()dependencies. Phase 4 adds the lifecycle check after those authorization dependencies. - Record the effective method/path pairs and mounted pack generation.
- If the pack implements
AdminSurfaceProvider, collect and validate its contributions. - Resolve requirements and backing state.
- Atomically call the lifecycle coordinator's
subscribe_and_snapshot()under its lock. Reconcile against the returned active generations and event cursor, then replay later events before serving requests. - Store
AdminUIServiceonapp.state.
Scratch routers prevent partial registration if a pack raises midway through
register_api_routes(). Route requirements use canonical method and path
templates rather than string prefixes. A pack route cannot shadow a core route
or an earlier effective pack route. API-only routes are valid and need not have
a UI contribution.
The composite route dependency replaces separate router-level lifecycle and
endpoint-level role ordering. It runs the mandatory VIEWER baseline and the
route's stronger require_role() or require_permission() policy first, then
checks the active mounted generation. This guarantees unauthenticated and
unauthorized callers receive 401 or 403; only authorized callers can
receive lifecycle 503.
Admin application assembly must be centralized and reused by the production
WebServer and standalone admin app construction so ownership, baseline
dependencies, and registry behavior cannot drift.
Runtime pack lifecycle¶
Lifecycle prerequisite¶
The admin UI must not infer runtime readiness from engine.content_packs.
Before runtime reflection ships, all load, unload, reload, file-watch, and admin
API mutation paths must publish through one serialized lifecycle coordinator.
The coordinator owns loading, active, disabling, and failed states and a
monotonic pack generation.
For a load, it publishes active only after systems, commands, schemas, and
on_load() complete and rollback is no longer required. For unload, it marks
the pack disabling before teardown, blocks new guarded requests, drains
in-flight pack route requests, and publishes the final committed state after
teardown. Failed operations do not publish an active generation.
subscribe_and_snapshot() closes the race between initial manifest assembly
and lifecycle subscription, including file-watch changes before the FastAPI
lifespan begins. AdminUIService then consumes committed coordinator events
from the returned cursor. On a committed change it:
- Rebuilds affected contributions against the active generation.
- Commits an immutable manifest snapshot and increments its revision.
- Broadcasts
admin.ui.changedwith only the new revision over a newadmin-uiWebSocket channel. - Causes the frontend to revalidate
GET /admin/ui/manifest.
The WebSocket sends an invalidation signal, not the manifest payload. The HTTP
request remains role-filtered and cacheable. The new channel uses the existing
origin validation and connect-time VIEWER authentication; it is not described
as an existing per-channel role gate.
When the WebSocket is disconnected, the frontend revalidates every 30 seconds
and on window focus. ETag handling should make unchanged polling return 304.
Route policy for the first implementation¶
Routes mounted at startup remain in the FastAPI application. Every pack-owned route uses the composite authorization-and-lifecycle dependency described in startup registration. Its lifecycle check returns:
with status 503 when the owning generation is not active.
The composite dependency checks the coordinator's active mounted generation,
not dictionary membership. It executes the mandatory VIEWER baseline and any
stronger route-declared require_role()/require_permission() policy first.
Unauthenticated or unauthorized requests remain 401 or 403; only an
authorized request to an inactive owner receives 503.
A pack loaded after startup can register its descriptors in the surface
registry. With the Phase 6 dynamic host enabled, a route-backed surface becomes
enabled only after the complete candidate route generation and manifest
mapping commit. With the host disabled, a route-backed surface whose routes
were not mounted is marked restart_required and disabled. Route-independent
design and documentation surfaces may become available immediately.
Likewise, code reload of a route-owning pack is enabled after the new dynamic
handler, dependencies, response models, OpenAPI schema, and endpoint mapping
commit. With the host disabled it remains restart_required, because the
startup-mounted APIRoute still references the old generation. The registry
tracks mounted and dynamic route generations, not only owner name.
This policy is intentionally conservative:
- It prevents stale mounted routes from behaving as if an unloaded pack were active.
- It tells operators why a late-loaded route-backed feature is disabled.
- It avoids unsupported mutation of the main application's route list.
Implemented dynamic route host¶
Runtime route activation uses one engine-owned ASGI dispatcher mounted at
/admin/ext during centralized startup assembly. The parent application's
route list is never changed by lifecycle operations. The dispatcher holds an
immutable child-application generation; each request acquires one snapshot,
and retired snapshots are reclaimed only after their requests drain.
Canonical pack routes remain /admin/.... The authoritative manifest mapping
is:
Thus /admin/demo-pack/status maps to
/admin/ext/demo-pack/demo-pack/status, while stdlib's
/admin/entities/ maps to /admin/ext/stdlib/entities/. Existing startup
paths remain guarded compatibility routes. The fixed frontend resolves only
manifest-declared method/template pairs and preserves typed path and query
parameters; it never accepts arbitrary origins, headers, bodies, or methods.
The coordinator serializes lifecycle mutations and follows this transaction:
- Derive prospective committed pack state and build the complete child candidate through the Phase 2 scratch-router validation pipeline.
- Publish the transitional lifecycle state and stop new admissions to the old pack generation; unload/reload waits for bounded route draining.
- Perform the runtime pack mutation while the old host remains published but lifecycle-guarded.
- Rebuild from the exact runtime instance when necessary, then synchronously swap the host generation and commit lifecycle state, registry projection, manifest revision, endpoint mapping, and invalidation without yielding.
- On candidate or swap failure, retain the prior host/schema, roll runtime state back when possible, or publish an honest failed/degraded state.
Child routes receive the parent admin protection stack and an explicit
VIEWER baseline before stronger pack authorization and exact
generation/instance lifecycle admission. Only allowlisted trusted application
state is propagated. WebSockets and automatic child docs are rejected. The
authenticated, generation-scoped schema is available at
GET /admin/ext/openapi.json.
The typed flag AdminSettings.dynamic_route_host_enabled is enabled by
default and controlled by
MAID_ADMIN_DYNAMIC_ROUTE_HOST_ENABLED. Setting it to false at startup
retains the Phase 4 frozen-route and restart_required fallback.
Direct mutation of the main admin application's route list is not part of this RFC.
Frontend behavior¶
Navigation¶
Layout.tsx builds navigation from the manifest:
- Group by
section. - Sort by
orderand stable identifier. - Hide surfaces filtered by role.
- Render enabled SPA surfaces with
NavLink. - Render design surfaces with a visible Design only badge.
- Render hybrid surfaces with separate local-draft and live-operation labels.
- Hide degraded or restart-required surfaces from routine navigation and show their accessible reason text in Pack Inventory and direct-link status views.
- Omit unknown view identifiers from normal navigation while showing their errors in Pack Inventory.
Core and pack surfaces use the same rendering path.
Navigation role filtering and page actions are separate. A viewer may open
Entities, World, Config, or Logs while stronger actions on those pages require
BUILDER, MODERATOR, ADMIN, or SUPERADMIN. The manifest returns action
entitlements for the current user; audited views hide or disable unavailable
controls with a reason. Backend endpoint checks remain authoritative. Tests
cover all five admin roles and the account SYSOP to admin SUPERADMIN
mapping.
Routing¶
The bundled route-to-component registry remains static, but route availability
comes from routes, not from navigation items. A route guard resolves one of:
- The registered view.
403 Insufficient role.Unavailablewith missing pack or capability information.Restart required.Degradedwith retry and diagnostic information.
Deep links to known parameterized views remain understandable even when their
pack is absent. Role-filtered route tombstones allow a 403 without revealing
descriptor details. The frontend must not redirect every unknown, forbidden,
or unavailable route silently to the dashboard.
Pack Inventory¶
Add an engine-owned Pack Inventory page using the existing pack API and UI manifest. For each discovered pack it shows:
- Separate availability, runtime load state, and admin UI status.
- Version and load order.
- Dependencies and dependents.
- Declared capabilities.
- Contributed surfaces and their backing state.
- Startup-mounted admin routes.
- Contribution errors and missing requirements.
- Role-gated load, unload, and reload actions, enabled only after committed lifecycle synchronization and route guards ship.
The dashboard pack summary should use the same separate state dimensions.
Honest data presentation¶
Quest and dialogue editors are hybrid, not purely design-only: their graph
data is browser-local, but they load and delete live entities. Their banners
must distinguish local draft actions from live actions and show the role
required by each. Alternatively, implementation may remove live entity
operations and reclassify them as design-only.
Browser-local drafts are namespaced by server identity, world, user, owning pack, view-contract version, and entity ID. Draft schema changes define migration or export behavior. YAML Import/Export and Play Mode recordings must receive the same backing-state and storage review.
Balance is engine-owned. A pack can become the provider of an individual widget only by registering a live balance provider. Every widget response exposes provenance:
Live responses identify the provider pack and observation time. An empty live
world uses data_source: "empty" and no chart is rendered. Unconfigured
providers use unavailable; request or provider errors use failure. Only
live data is rendered as a widget. Combat, economy, and content widgets can
have different provenance.
No API or frontend helper may catch an arbitrary error and return a success-shaped placeholder.
Security¶
- Endpoint authorization remains mandatory and independent of UI visibility.
- Navigation is filtered server-side by role; forbidden route tombstones are redacted.
- Response-specific ETags plus
Vary: Cookie, Authorizationprevent cross-role cache confusion. - WebSocket invalidation uses existing origin validation and connect-time
VIEWERauthentication. - Surface identifiers, reserved namespaces, roles, paths, methods, URLs, parameters, icons, sections, action IDs, and view IDs are validated.
- Data endpoints are same-origin, read-only, pack-owned routes under
/admin/. - External links allow only approved
httpsorigins and userel="noopener noreferrer". - Pack text renders as text or through an audited sanitizer; raw HTML and scripts are forbidden.
- Pack route ownership and required-route checks are diagnostics, not grants.
- Complete per-route authorization is applied by engine assembly before the lifecycle check.
- Content packs remain trusted server-side code under MAID's existing trust model. This RFC does not make them safe to install from untrusted sources.
Failure behavior and observability¶
| Failure | Required behavior |
|---|---|
get_admin_contributions() raises |
Skip that pack's contributions, log the exception, and mark its admin UI status degraded. |
register_api_routes() raises |
Discard the pack's entire scratch router; no partially registered routes are mounted. |
| One descriptor is invalid | Skip only that descriptor and record a structured contribution error. |
| Namespace mismatch or reserved prefix | Reject with fixed code id_namespace_mismatch or reserved_namespace. |
| Duplicate ID, SPA path, or HTTP method/path | Reject the later contribution; engine definitions always win. |
| Required method/path is missing | Mark the route unavailable or restart-required. |
| Unknown frontend view | Hide from normal navigation and report it in Pack Inventory. |
| Manifest endpoint fails | Preserve a user- and role-scoped last valid manifest; on first load show a minimal engine fallback with a degraded banner. |
| WebSocket disconnects | Revalidate through polling and on window focus. |
| Pack unloads while its page is open | Transition to Unavailable and announce the change. |
Add structured logs and metrics for:
- Registered surfaces by owner and status.
- Invalid contributions.
- Identifier and route collisions.
- Missing capabilities or routes.
- Manifest revisions.
- Runtime transitions requiring restart.
- Active and mounted pack generations.
A startup/CI validation mode should compare declared method/path requirements with owned mounted routes. It also reports pack routes that have no declared surface, but API-only routes are valid and do not fail validation.
Accessibility¶
- Use semantic navigation and
aria-current="page". - Communicate Live, Design only, Degraded, and Restart required with text, not color alone.
- Give disabled items accessible reason text instead of relying on
title. - Announce runtime availability changes through a polite live region.
- Move focus to the page heading after navigation.
- Keep unavailable and insufficient-role pages keyboard accessible.
- Honor reduced-motion preferences.
Alternatives¶
Derive the panel from OpenAPI or route inspection¶
Rejected as the primary contract. Routes lack ownership and UX semantics, and they do not solve runtime route-table drift. Retain introspection for verification.
Extend only /admin/packs¶
Rejected. Pack lifecycle metadata and user-facing admin surfaces evolve at
different rates and have different role-filtering and caching needs. The UI
manifest may include a pack summary while /admin/packs remains authoritative
for lifecycle operations.
Keep hardcoded navigation with feature flags¶
Rejected as the end state. It requires engine releases for pack-specific visibility and recreates the existing drift.
Allow pack-provided frontend bundles¶
Rejected for this RFC. It adds dependency negotiation, browser supply-chain risk, CSP complexity, frontend compatibility problems, and a new packaging system before MAID has exhausted generic or bundled views.
Mutate FastAPI routes on every pack lifecycle event¶
Rejected for the first implementation. The main route list is order-sensitive, OpenAPI state is cached, and there is no supported atomic removal operation.
Drawbacks¶
- The descriptor schema and view registry become public extension contracts that require versioning.
- A pack may need an engine/frontend release before it can reference a new specialized view.
- Runtime-loaded route-backed surfaces require a restart in the initial implementation.
- Route guards require changes to existing pack-owned routers.
- Pack lifecycle, route lifecycle, and surface lifecycle become distinct concepts that documentation must explain.
- The frontend needs explicit unavailable and degraded states rather than relying on generic React Query errors.
Compatibility and versioning¶
AdminSurfaceProvideris optional and does not change the structuralContentPackprotocol.- Existing packs without contributions continue to load and appear in Pack Inventory.
- The existing
/admin/packsAPI remains unchanged. /admin/editor/extensionsretains its response shape and is backed by the new registry.- The manifest includes an integer
contract_version. - Clients ignore unknown additive fields and unsupported additive contribution
kinds;
view_paramsremain schema-validated by view version. - Breaking descriptor changes increment
contract_version. - View identifiers are versioned when their parameter contracts change.
- The initial frontend rollout may keep current navigation only when the dynamic feature is explicitly disabled. Once enabled, a runtime manifest failure falls back to a minimal engine allowlist, never the legacy sidebar containing pack-dependent links.
- Manifest cache entries are scoped by server identity, user, and effective role and are cleared on logout or role change.
Implementation plan¶
Phase 0: Establish honest current behavior¶
- Inventory every current navigation and detail route, its owner, backing, prerequisite APIs, page actions, and action roles.
- Document live, design, and hybrid existing pages.
- Add per-widget provenance to balance responses and remove silent frontend fallback.
- Namespace browser-local drafts and recordings.
- Correct CLI examples to use content-pack manifest names such as
stdlib.
Exit criteria: Operators cannot mistake design or reference data for live world state, and every existing frontend route has a migration record.
Phase 1: Add the registry and manifest¶
- Add
AdminSurface, status, owner, and error models. - Add the discriminated route, navigation, inspector, and toolbar contracts.
- Add the shared, versioned view/action/parameter catalog.
- Implement
AdminUIServiceand its registry. - Seed engine surfaces.
- Add the role-filtered, ETag-aware
/admin/ui/manifest. - Add registry unit and endpoint tests.
Exit criteria: The server can describe its engine admin surface without frontend changes.
Phase 2: Add pack contributions¶
- Add optional
AdminSurfaceProvider; do not changeContentPack. - Declare stdlib surfaces.
- Declare Classic RPG live and hybrid routes and live balance providers where appropriate; keep the balance page engine-owned.
- Back
/admin/editor/extensionsfrom the registry. - Centralize admin app assembly.
- Add isolated scratch-router registration, mandatory baseline authentication, route ownership, atomic inclusion, and collision validation.
Exit criteria: Manifest output differs correctly for engine-only, stdlib,
and stdlib plus Classic RPG startup combinations; structural legacy packs still
satisfy ContentPack.
Phase 3: Make the frontend data-driven¶
- Add the UI manifest API client and TypeScript models.
- Add the audited frontend view registry.
- Render navigation and route guards from manifest data.
- Add Pack Inventory, Unavailable, Degraded, Restart required, and 403 views.
- Add per-action entitlement handling, role, cache, and accessibility tests.
- Keep the dynamic-manifest feature flag off. It must not be enabled in any environment before Phase 4 while runtime mutation APIs, file watching, or other lifecycle paths can stale the registry.
- When eventually enabled, use only the last valid scoped manifest or minimal core fallback.
- Keep pack lifecycle controls disabled or read-only until Phase 4.
Exit criteria: The data-driven frontend is complete behind a disabled flag; tests prove the legacy mode remains active and direct runtime mutations cannot expose a stale dynamic manifest because that mode cannot be enabled.
Phase 4: Reflect runtime changes¶
- Route all runtime mutation paths through a serialized lifecycle coordinator with committed state, generation, rollback, and request draining.
- Update the registry only from committed lifecycle events.
- Broadcast manifest revision invalidations.
- Add a dedicated manifest client that handles
304, polling fallback, window-focus refresh, logout, and role changes. - Add composite authorization-and-lifecycle dependencies to pack-owned startup routes.
- Mark late-loaded and code-reloaded route-backed surfaces restart-required.
- Enable Pack Inventory lifecycle controls.
Exit criteria: Runtime changes update the UI without stale navigation, and stale startup route generations cannot operate as active pack features. The dynamic-manifest flag may now be enabled.
Phase 4 implementation record¶
PackLifecycleCoordinatoris engine-scoped and is the authority for runtime load, unload, code reload, file-watch reload, committed generations, and startup-route admissions. The old hot-reload and runtime registration APIs are compatibility shims; after admin assembly seals startup they reject mutations that do not originate in the coordinator.- Main-app pack routes remain frozen. Their composite dependency runs all
declared authorization dependencies before admitting the committed startup
generation. Reloaded handlers and late-loaded route-backed surfaces return
or display
pack.restart_required; UI-only late contributions may enable immediately. - Unload and reload commit
disabling, reject new admissions, and wait up to the configured drain bound. A timeout restores the prior active commit without beginning teardown. Failed code-reload work that cannot prove a complete rollback is committed as failed/degraded rather than reported as a success. AdminUIServicerebuilds only from coordinator commits and publishes revision-only invalidations at authenticated/admin/ui/ws. Both lifecycle and manifest notifiers provide atomic subscribe-and-snapshot operations.- The frontend revalidates on revision invalidation, reconnects with bounded backoff, polls every 30 seconds while disconnected, and refreshes on focus. Its cache key includes server, world, user, role, and diagnostics scope. Older responses cannot replace newer revisions. An opaque server-incarnation and world scope token permits revision reset after restart/world replacement without briefly rendering data from the prior scope.
- Dynamic manifest mode is the checked-in default. Emergency legacy navigation
requires the explicit
?admin-ui-mode=staticquery parameter or themaid-admin-emergency-static=1session key; it is not a routine fallback. ReloadManager.reload_content_pack()remains a module-cache development operation: it has no engine reference and cannot change the loaded pack instance, loader state, systems, commands, routes, or committed admin metadata. Runtime pack lifecycle/code-reload paths useHotReloadManager,ContentPackReloader, or the coordinated pack file watcher instead.
Phase 5: Extend generic administration¶
- Attribute component types and document schemas to packs.
- Add pack filtering to generic entity/component views.
- Add generic REST panels and inspector tabs.
- Publish a content-pack author guide and validation tooling.
Authoring reference: Content-pack admin contributions.
Exit criteria: A pack can gain useful administration support without custom React code.
Phase 6: Ship the approved dynamic route host¶
- [x] Mount an isolated
/admin/extdispatcher once in both application factories through centralized assembly. - [x] Prove atomic rebuild, rollback, draining, cancellation, concurrency, route ordering, OpenAPI cache, middleware, and authorization behavior.
- [x] Integrate lifecycle transactions, authoritative endpoint mappings, fixed frontend client resolution, Pack Inventory diagnostics, and documentation.
- [x] Retain the typed emergency-disable flag and enable the checked-in default after the backend and frontend matrix passes.
Exit criteria: A tested isolated dynamic host ships without mutating the main admin application's live route list.
Test matrix¶
| First phase | Scenario | Expected result |
|---|---|---|
| 1 | Engine only | Only engine surfaces appear; known pack deep links show Unavailable. |
| 2 | Stdlib only | Entities, Players, World, Map, and enabled stdlib features appear. NPC and base item detail deep links work without Classic RPG. |
| 2 | Stdlib plus Classic RPG | Classic RPG sections and hybrid routes appear with correct backing state. |
| 2 | Tutorial/custom pack without contributions | Pack appears in inventory and adds no navigation. |
| 5 | Custom pack with valid generic contribution | Typed view_params render without executing pack JavaScript. |
| 2 | Invalid descriptor | Only that descriptor is skipped; pack admin UI status is degraded. |
| 2 | Contribution method raises | Other packs and core navigation remain usable. |
| 2 | Route registration raises midway | No routes from that scratch router are mounted. |
| 2 | Method/path or SPA path collision | Later contribution is rejected with a fixed diagnostic code. |
| 4 | Runtime unload | Navigation disappears, open page becomes unavailable, guarded route returns 503. |
| 4 | Runtime late load | Route-independent surfaces may activate; unmounted route-backed surfaces require restart. |
| 4 | Runtime code reload | Route-owning surfaces require restart and old handlers are not advertised active. |
| 3 | Every admin role | Navigation, route tombstones, and action entitlements match VIEWER through SUPERADMIN. |
| 4 | WebSocket unavailable | Polling updates the manifest within 30 seconds. |
| 3 | Manifest endpoint unavailable | Last valid data or minimal core fallback appears with a degraded banner. |
| 4 | Conditional fetch | 304 reuses the scoped cached body; logout and role change clear it. |
| 0 | Balance widgets | Only live data renders; empty, unavailable, and failure provenance hide the affected widget without substitution. |
| 0 | Hybrid drafts | Local data is isolated by server, world, user, pack, view version, and entity. |
Phase 0 decision record¶
The original unresolved questions are resolved as follows:
- Contract version 1 supports Python and TOML declarations. TOML support is a contract decision only in Phase 0; parsing and validation ship with the manifest/registry phases.
- Every engine-owned surface is visible to
VIEWER. Individual actions retain their stronger endpoint roles. - Unavailable surfaces are hidden from routine navigation and shown with their reason in Pack Inventory.
- Balance reference curves are removed. There is no explicit-reference request mode. Only live observations render; empty, unavailable, and failure states hide the affected widget.
- Versioned frontend view IDs use best-effort compatibility. The frontend first resolves an exact ID, then may use the newest bundled compatible view in the same view family when its validated parameter contract is compatible. Unknown additive fields are ignored. A view with incompatible required parameters, semantics, or major contract version is unavailable rather than guessed, and Pack Inventory records the reason.
- The isolated dynamic extension route host is approved and must ship after lifecycle coordination. Phase 6 is implementation, not another go/no-go evaluation.
Phase 0 current-surface inventory¶
This is the migration record for the hardcoded frontend in
admin_frontend/src/App.tsx and components/Layout.tsx as of July 12, 2026.
Roles below are endpoint minimums (V = VIEWER, M = MODERATOR, B = BUILDER,
A = ADMIN, S = SUPERADMIN). Client-only actions inherit the route's display
role. The current frontend does not yet hide actions by role; the entries below
are the authoritative requirements the manifest must expose.
| Current path / sidebar label | Migration route ID; owner | Backing | Prerequisite APIs | Current page actions and required roles |
|---|---|---|---|---|
/login / none |
core.login; engine |
live | POST /admin/auth/login, refresh and current-user auth |
Sign in: unauthenticated. |
/ / Dashboard |
core.dashboard; engine |
live | GET /admin/dashboard/ (V), GET /admin/packs/ (V), dashboard WebSocket (V), global maintenance status (V) |
Read metrics and pack summary (V). Recent activity is a session-scoped cache of live events, not design data. |
/map / Map Editor |
stdlib.map; stdlib |
live | stdlib world room/graph APIs (V), world writes (B/A), engine editor locks and WebSocket (V/B) | Browse, select, copy IDs, presence (V); acquire/renew/release locks, create/update rooms and exits (B); delete rooms (A). Undo/redo inherits the role of its underlying operation. |
/entities / Entities |
stdlib.entities; stdlib |
live | stdlib /admin/entities list/detail/types (V) and writes (B/A) |
Browse/filter/view (V); create entity, edit components, add/remove tags (B); delete entity (A). |
/players / Players |
stdlib.players; stdlib |
live | stdlib /admin/players (M) |
Browse/view, ban, unban, kick, and message (M). The current sidebar incorrectly shows this to VIEWER even though its first request requires MODERATOR. The unused access-level API requires A. |
/world / World Data |
stdlib.world; stdlib |
live | stdlib room, area, graph, and exit reads (V), writes (B/A) | Browse/list/graph (V); create/update rooms and create/delete exits (B); delete rooms (A). Undo/redo inherits the underlying role. |
/logs / Logs |
core.logs; engine |
live | log search/recent/loggers/stats (V); /admin/logs/stream WebSocket (M) |
Search/filter/read and clear the client display (V); real-time stream (M). The current page attempts the M stream for VIEWER. |
/config / Config |
core.config; engine |
live | config overview/section/value (V), overrides/audit and validate (A), mutations (S) | Read configuration (V); view overrides and validate proposed changes (A); update values, reload, and clear overrides (S). Controls are currently rendered before entitlement filtering. |
/browse / Content |
stdlib.content; stdlib |
live | stdlib entity list (V) | Search/filter/sort and navigate to known detail editors (V). |
/bulk / Bulk Edit |
stdlib.bulk; stdlib |
live | stdlib entity and world reads (V), component/room updates (B), entity delete (A) | Filter/select and CSV export/preview (V); save edits and room CSV import (B); delete selected entities (A). |
/yaml / Import/Export |
stdlib.yaml; stdlib |
hybrid | stdlib entity and world reads (V), room update (B) | Generate/download YAML and parse an in-memory import preview (V); apply room updates to the live world (B). Import text is not persisted. NPC/item apply is unavailable. |
/play-mode / Play Mode |
core.play-mode; engine |
design | none beyond authenticated shell | Mock connect/commands, record/play/delete and export recovery data (V, browser only). No action contacts a game server. |
/npcs/:id / none |
stdlib.npc-detail; stdlib |
live | stdlib entity detail (V), component update (B), entity delete (A); NPC component availability | Load/edit view (V); save component changes (B); delete entity (A). |
/items/:id / none |
stdlib.item-detail; stdlib |
live | stdlib entity detail (V), component update (B), entity delete (A); base item components, with Classic RPG sections capability-gated | Load/edit view (V); save component changes (B); delete entity (A). |
/quests/:id / none |
classic-rpg.quest-detail; classic-rpg |
hybrid | stdlib entity detail (V), browser-local scoped draft, entity delete (A) | Edit graph, save scoped draft, preview, import/export legacy recovery, export YAML (V, local); delete the live entity (A). No runtime quest component is written. |
/dialogues/:id / none |
classic-rpg.dialogue-detail; classic-rpg |
hybrid | stdlib entity detail (V), browser-local scoped draft, entity delete (A) | Edit graph, save scoped draft, preview, import/export legacy recovery, export YAML (V, local); delete the live entity (A). No dialogue graph component is written. |
/balance / Balance |
core.balance; engine |
live per widget | engine /admin/balance/{combat,economy,content} (V) |
Read only (V). Combat and economy are currently unavailable; live content density renders when a real world has rooms. Empty, unavailable, and failed widgets are hidden. |
* / none |
no surface; client fallback | none | none | The current app redirects unknown paths to /. Phase 3 replaces this with explicit unknown/forbidden/unavailable handling. |
The sidebar has exactly the twelve bold labels above. Detail routes and login
do not appear in it. There is no current /packs client route or Pack
Inventory page; pack status is only summarized on Dashboard. The shared layout
also exposes authenticated logout and the engine-owned maintenance banner.
Browser-local storage contract established in Phase 0¶
Quest drafts, dialogue drafts, and Play Mode recordings use:
maid-browser-state:v1:
<server-origin>:<world-id>:<user-id>:<owner>:<view-contract-version>:
<kind>:<entity-id>
Segments are URL-encoded. The admin origin supplies the stable server identity.
No current admin API exposes the active world identity, so Phase 0 uses the
explicit default world sentinel; replacing it with a stable world ID requires
that API/manifest field in a later phase. Legacy keys are never deleted or
silently claimed by the current user. The relevant page detects them and
offers explicit scoped import or file export. Malformed values remain intact
and produce a visible recovery warning.
Acceptance criteria¶
- Navigation reflects the packs loaded at server startup.
- Every visible pack-owned surface identifies its owner and backing state.
- No design-only or reference data is presented as live state.
- Invalid pack contributions cannot break core admin navigation.
- Existing structural packs remain compatible with the runtime-checkable
ContentPackprotocol. - Endpoint role checks remain mandatory.
- Mixed-role pages expose only actions available to the current user.
- Runtime unload removes or disables relevant UI immediately and guards stale mounted routes.
- Runtime late load or code reload never produces an enabled link to an unmounted or stale handler generation.
- Existing packs without admin contributions remain compatible.
- No pack-provided JavaScript executes in the admin browser.
- The panel remains operable when the manifest or WebSocket is temporarily unavailable.
Revision history¶
| Date | Change |
|---|---|
| 2026-07-10 | Initial draft. |
| 2026-07-12 | Recorded Phase 0 decisions and complete current-surface inventory; prohibited reference balance data and approved the future dynamic route host. |
| 2026-07-13 | Implemented Phase 6: atomic /admin/ext generations, lifecycle transactions, manifest endpoint mappings, frontend resolution, authenticated schema, diagnostics, emergency fallback, and test matrix. |