Skip to content

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/manifest endpoint.
  • 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.tsx hardcodes sidebar navigation.
  • packages/maid-engine/admin_frontend/src/App.tsx hardcodes React routes.
  • packages/maid-engine/src/maid_engine/api/admin/router.py mounts engine admin routers and then calls register_api_routes() on packs loaded when the FastAPI app is created.
  • packages/maid-stdlib/src/maid_stdlib/pack.py contributes the entity, player, world, and memory APIs.
  • packages/maid-engine/src/maid_engine/api/admin/packs.py reports loaded and available packs, but not the admin surfaces they provide.
  • packages/maid-engine/src/maid_engine/api/admin/extensions.py defines an editor-extension response, but the normal application does not populate it.

This creates several misleading states:

  1. A navigation item can be visible while its backend route is absent because the owning pack was not loaded.
  2. A loaded pack can provide components, systems, or APIs without the admin UI exposing or attributing them.
  3. Runtime pack loading and unloading can change engine.content_packs without changing the FastAPI route table created at startup.
  4. Quest and dialogue editors store design data in browser localStorage and export YAML, while other screens edit live state.
  5. 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.entities using the bundled entity browser.
  • stdlib.world using the bundled world browser.
  • core.balance using the bundled balance dashboard and registered providers.
  • classic-rpg.quest-designer using 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 owned GET method/path pair, a response shape (key_value or table), 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/extensions endpoint.

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, and target_types become EditorTab.
  • Toolbar id, label, icon, and action_id become EditorToolbarItem.action.
  • Contributions are grouped by owner into EditorExtension.

UI manifest API

Add:

GET /admin/ui/manifest

The endpoint requires at least VIEWER, filters navigation and action entitlements to the current role, and returns an ETag with:

Cache-Control: private, no-cache
Vary: Cookie, Authorization

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:

GET /admin/ui/manifest?include_diagnostics=true

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:

  1. Create the registry.
  2. Register engine surfaces.
  3. Iterate loaded packs in engine load order.
  4. Call each pack's existing register_api_routes() against an isolated scratch APIRouter.
  5. If route registration succeeds, validate all method/path pairs, reserved paths, duplicates, response models, and collisions before including the scratch router atomically.
  6. Attach an explicit VIEWER authorization baseline to every pack route, including routes whose pack omitted authentication. Preserve and compose stronger pack-provided require_role() or require_permission() dependencies. Phase 4 adds the lifecycle check after those authorization dependencies.
  7. Record the effective method/path pairs and mounted pack generation.
  8. If the pack implements AdminSurfaceProvider, collect and validate its contributions.
  9. Resolve requirements and backing state.
  10. 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.
  11. Store AdminUIService on app.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:

  1. Rebuilds affected contributions against the active generation.
  2. Commits an immutable manifest snapshot and increments its revision.
  3. Broadcasts admin.ui.changed with only the new revision over a new admin-ui WebSocket channel.
  4. 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:

{
  "code": "pack.unloaded",
  "detail": "The owning content pack is not loaded"
}

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:

/admin/<canonical remainder>
    -> /admin/ext/<owner manifest name>/<canonical remainder>

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:

  1. Derive prospective committed pack state and build the complete child candidate through the Phase 2 scratch-router validation pipeline.
  2. Publish the transitional lifecycle state and stop new admissions to the old pack generation; unload/reload waits for bounded route draining.
  3. Perform the runtime pack mutation while the old host remains published but lifecycle-guarded.
  4. 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.
  5. 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

Layout.tsx builds navigation from the manifest:

  • Group by section.
  • Sort by order and 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.
  • Unavailable with missing pack or capability information.
  • Restart required.
  • Degraded with 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:

{
  "data_source": "live",
  "provider": "classic-rpg",
  "observed_at": "2026-07-12T20:00:00Z"
}

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, Authorization prevent cross-role cache confusion.
  • WebSocket invalidation uses existing origin validation and connect-time VIEWER authentication.
  • 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 https origins and use rel="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

  • AdminSurfaceProvider is optional and does not change the structural ContentPack protocol.
  • Existing packs without contributions continue to load and appear in Pack Inventory.
  • The existing /admin/packs API remains unchanged.
  • /admin/editor/extensions retains 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_params remain 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 AdminUIService and 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 change ContentPack.
  • 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/extensions from 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

  • PackLifecycleCoordinator is 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.
  • AdminUIService rebuilds 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=static query parameter or the maid-admin-emergency-static=1 session 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 use HotReloadManager, 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/ext dispatcher 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:

  1. 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.
  2. Every engine-owned surface is visible to VIEWER. Individual actions retain their stronger endpoint roles.
  3. Unavailable surfaces are hidden from routine navigation and shown with their reason in Pack Inventory.
  4. 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.
  5. 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.
  6. 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 ContentPack protocol.
  • 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.