ADR-044 — menu-api Module for Generalized Interactive Menus¶
Status: Proposed
Date: 2026-05-15
Target release: 3.0.0-beta.3 (surface + reflector already shipped under rtp-api/rtp-core); module extraction lands once Stage 5 of CHECKLIST-generalized-menu.md completes.
Supersedes (in part): the prior "Command-Tree Menu Reflector" draft of this ADR (which placed CommandTreeMenuBuilder permanently in rtp-core).
Context¶
ADR-035 (Accepted, 2026-05-15 amendment) defines the platform-neutral menu primitive: MenuModel / MenuPage / MenuLine / MenuFragment / MenuAction plus MenuRenderer / MenuTokenRegistry / MenuConsumerProfile / MenuOpenRequest / YamlCommentLookup. The first concrete implementation has already shipped under the staged checklist at docs/dev/scratch/CHECKLIST-generalized-menu.md:
- Stage 1 — sealed surface placed in
rtp-api/.../menu/(10 types, no platform imports). - Stage 2 —
LocalMenuTokenRegistry,MenuRedeemSubcommand,CommandTreeMenuBuilder,ConfigMenuConsumerProfileinrtp-core/.../commands/menu/. - Stage 3 —
/rtp menuregistration inrtp-plugin,messages.ymlchrome keys, traceability rows. - Stage 4 —
BookMenuRendererinrtp-paper-common,menu.renderer: [book]config, ordered-preference renderer selection. - Stage 5 (in flight) — Spigot per-version renderers, Fabric
ChatMenuRenderer, paginated chat renderer on Paper/Folia/Spigot, region-picker //rtpadminwizard consumers.
Two structural facts about the resulting subsystem now match the criteria that justified pulling effects-api and commands-api (and, per ADR-046, maps-api) into top-level sibling modules:
- The menu surface is a platform-neutral SPI consumed by every platform adapter and by future addons. It is not RTP-domain logic.
MenuModel/MenuAction/MenuRenderer/MenuTokenRegistrydo not reference anyrtp-apidomain type (RTPLocation,Region,RTP, hook registries). Conversely, every platform adapter must ship at least one renderer (BookMenuRendereron Paper/Folia, version-branched on Spigot,ChatMenuRendereron Fabric), and Stage 5.5 anticipates addon-authored consumers. - The Fabric path will require an obf/unobf carrier split. Once Stage 5.2 lands
ChatMenuRendereron Fabric, the same Mojmap-unobf carrier shape thateffects-api-fabric-unobf(effects-api-ADR-006) andmaps-api-fabric-unobf(ADR-046) use — driven by rtp-fabric-ADR-009 — applies. Hosting that carrier insidertp-apiis structurally wrong:rtp-apiexplicitly avoids platform carriers.
A third fact about the dependency graph was clarified by the user on 2026-05-15:
rtp-apiis permitted to depend on sibling*-apimodules. This removes the historical objection (that pulling acommands-apidependency intortp-apiwould leak the Brigadier surface to addons) and makes amenu-api → commands-apidirect dependency expressible without forcing the menu code to live inrtp-core. The menu reflector is intrinsically coupled tocommands-api(it walksTreeCommand#getCommandLookup()/getParameterLookup()and filters byCommandsAPICommand#permission()); the dependency edge belongs at the module boundary, not hidden insidertp-core.
The remaining decision points — applicability semantics (hide vs. grey), hover-text resolution, per-consumer hooks, the live-tree SSOT property, mint-time vs. redeem-time hover — were settled by the prior draft of this ADR and the ADR-035 amendment. This revision does not relitigate them; it relocates the implementation.
Decision¶
A new top-level module menu-api/ (sibling to effects-api/, commands-api/, and the proposed maps-api/) shall host the generalized menu subsystem. The module is structured in three layers, mirroring ADR-046:
- Layer 1 — Surface (
menu-api/.../menuapi/): the immutable sealed surface currently sitting inrtp-api/.../menu/—MenuAction(sealed:RunRtpCommand,ChangePage,SuggestInput,OpenExternalUrl),MenuFragment,MenuLine,MenuPage,MenuModel,MenuRenderer,MenuTokenRegistry,MenuConsumerProfile,MenuOpenRequest,YamlCommentLookup. No platform imports. Depends only oncommands-api(for theCommandsAPICommand/CommandParameter/TreeCommandtypes referenced byMenuConsumerProfileand the reflector contract). - Layer 2 — Reflector + token registry (
menu-api/.../menuapi/build/and.../menuapi/token/):CommandTreeMenuBuilder(pure function over(callerId, permissionPredicate, TreeCommand, pageIndex, MenuConsumerProfile) → MenuModel) andLocalMenuTokenRegistry(in-memory CAS-consume implementation ofMenuTokenRegistry). Both currently live inrtp-core/.../commands/menu/and move tomenu-apiunchanged. - Layer 3 — Bindings: renderer implementations. Bukkit-family renderers (
BookMenuRenderer, paginatedChatMenuRenderer) stay inplatforms/rtp-paper/rtp-paper-common/and Spigot per-version branches because they import Adventure / BungeeCord-Chat. Fabric renderers dispatch through amenu-api-fabric-unobf/Mojmap-built carrier paired with NM-typed obf carriers underplatforms/rtp-fabric/rtp-fabric-common/.../menu/, following the dispatch contract in effects-api-ADR-006 and rtp-fabric-ADR-009. Themenu-api-fabric-unobf/module is created only when Stage 5.2 confirms an NM-typed surface is unavoidable; pure-Adventure renderers do not justify it.
Module dependency layering¶
menu-api commands-api maps-api effects-api
\ | | /
\ | | /
+--------- rtp-api ---------+
|
rtp-core
|
rtp-{spigot,paper,folia,fabric}
|
rtp-plugin / addons
menu-apidepends oncommands-api(compile,apiscope). Nothing else. Nortp-api, nortp-core, no platform, no YAML library —YamlCommentLookupis a plain(String key) → Stringfunctional interface.rtp-apiadds a compile dependency onmenu-api(apiscope, so consumers pick it up transitively). The existing direct dependency oncommands-apistays, for IDE navigation clarity, even though it is now also reachable transitively.rtp-coreretains the consumer-side glue that bindsYamlCommentLookupto the in-house YAML substrate (ConfigMenuConsumerProfileand theMenuRedeemSubcommandwiring against the live/rtproot). These classes do not move; they continue to importmenu-apitypes.- Platform renderers (
BookMenuRenderer,ChatMenuRenderer, future Fabric carriers) move to the platform modules they already would have lived in hadmenu-apiexisted at Stage 4 — Stage 4'sBookMenuRendererlocation inrtp-paper-commonis already correct and does not relocate.
A small policy generalization, also satisfied by ADR-046: any *-api sibling module may depend on any other *-api sibling module; none of them may depend on rtp-api, rtp-core, or any platform module. This keeps the sibling tier a strict DAG and prevents commands-api (or future *-api siblings) from ever transitively pulling in rtp-core. Future *-api extractions cite this paragraph until it is promoted to its own project-wide policy ADR.
Reflection algorithm (unchanged from the prior draft)¶
Given (callerId: UUID, permissionCheckMethod: Predicate<String>, node: CommandsAPICommand, pageIndex: int, profile: MenuConsumerProfile):
- Applicability filter. If
permissionCheckMethod.test(node.permission()) == false, returnMenuModel.empty(localized("menu.empty")). - Title.
MenuModel.title = localized("menu.title", node.name()). - Children pass. Iterate
getCommandLookup().entrySet()in declaration order. Skip when the child's permission fails. Emit oneMenuFragmentper visible child withtext = child.name(),hover = child.description() ?? firstLine(child.help(...)),action = RunRtpCommand("menu", pathPrefix..., child.name()). - Parameters pass. Iterate
getParameterLookup().entrySet(). Skip when a parameter-level permission fails. Emittext = paramName + ":",hover = resolveParameterHover(node, paramName, parameter),action = SuggestInput(profile.suggestPrefix(node, paramName)). - Empty page. If neither pass emits a fragment, return a single
localized("menu.empty")line with no action. - Pagination. The renderer paginates; the builder returns the logical page for
pageIndexper ADR-035 + Stage 5.3.a'sMenuOpenRequestplumbing.
The builder is pure with respect to its inputs against the live tree; the live-tree SSOT property from the ADR-035 amendment is preserved.
Applicability semantics¶
Inaccessible nodes are hidden, not greyed out. Unchanged from the prior draft and from Stage 2's implementation. Rationale, alternatives, and the "hide-vs-disabled may be revisited per-consumer" caveat carry over.
Hover text resolution¶
resolveParameterHover(node, paramName, parameter) returns the first non-empty of:
- The consumer's
YamlCommentLookup.getComment(...)(the/rtp configconsumer dispatchesRtpYamlSection#getComment(canonicalPath(paramName)), preserved by ADR-042). - The localized
menu.hoverFallbackType/menu.hoverFallbackBoundscomposition overparameter.getClass().getSimpleName()and exposed bounds metadata. null— renderers omit theHoverEventrather than draw an empty tooltip.
Hover is built at mint time, dispatch is live. Unchanged from the prior draft.
Per-consumer hooks¶
The MenuConsumerProfile interface in menu-api (currently rtp-api/.../menu/MenuConsumerProfile.java) carries suggestPrefix + commentLookup + defaultProfile() and an optional includeParameter hook. ConfigMenuConsumerProfile (in rtp-core) binds the comment lookup to the live Configs registry and produces the /rtp config <file> <key>: prefix from CONFIG_COMMAND_SPEC §2.4. Future consumers (region picker, /rtpadmin wizards, addon-authored menus) ship their own profiles; no SPI extension is required.
Requirements¶
Four new requirements introduced (to be authored in docs/dev/REQUIREMENTS.md during the module extraction stage — see Migration / Rollout):
| REQ | Statement (target wording) |
|---|---|
REQ-RTP-MENU-001 |
A MenuRenderer shall throw IllegalStateException (S-006) when invoked before RTP core is loaded or with an offline viewer UUID for which no online resolver is available. |
REQ-RTP-MENU-002 |
A MenuTokenRegistry implementation shall atomically consume a token (CAS or equivalent) so concurrent redeems for the same token observe exactly one success. |
REQ-RTP-MENU-003 |
A MenuRedeemSubcommand shall reject a non-RunRtpCommand action with messages.yml → menuInvalid and a WARNING log entry (S-004 / S-007 / REQ-RTP-F-013). |
REQ-RTP-MENU-004 |
A CommandTreeMenuBuilder shall hide any child or parameter for which the caller's permission predicate returns false. |
The existing messages.yml chrome keys (menuInvalid, menuExpired, menuUnknownPlayer, menuHoverFallbackType, menuHoverFallbackBounds) remain in rtp-plugin's messages.yml; REQ-RTP-F-013 traceability already covers them.
Safety inheritance¶
All four S-00x prohibitions inherited by the menu subsystem (S-004 via MenuRedeemSubcommand logging, S-005 via mint-time-only command-tree walk + redeem dispatching through the existing async pipeline, S-006 via IllegalStateException on premature use, S-007 via messages.yml-sourced chrome) are unchanged from the prior draft. The module move does not alter any safety boundary; it only relocates the classes.
Alternatives Considered¶
| Alternative | Why Rejected |
|---|---|
Keep CommandTreeMenuBuilder permanently in rtp-core (the prior draft of this ADR) |
Forces a hidden commands-api ↔ rtp-core reflector edge instead of the explicit menu-api → commands-api module boundary; obstructs the imminent Fabric carrier work (Stage 5.2) which has no natural home in rtp-core; addons that want to render their own menus would have to depend on all of rtp-api to reach the surface types. |
Move only the surface (current rtp-api/.../menu/ contents) and leave the reflector in rtp-core |
Splits a single conceptual subsystem across two modules with no behavioral justification. The reflector is a pure function over commands-api types; it has no rtp-api dependency to anchor it. |
Hand-authored layout file per consumer (menus/config.yml etc.) |
Already rejected by the ADR-035 amendment: introduces a second SSOT; drifts whenever the command tree changes. Carried forward from the prior draft. |
A parallel MenuRegistry consumers populate at startup |
Same SSOT problem in a different package. Carried forward from the prior draft. |
| Snapshot the command tree at builder construction | Stale across runtime tree updates from the network-state layer. Carried forward from the prior draft. |
Fold menu-api into commands-api |
commands-api is a Brigadier bridge over typed command nodes; menu rendering is a presentation concern with its own per-platform carriers (Adventure book, BungeeCord-Chat, Fabric NM-typed packets). The two share neither a delivery path nor a per-viewer cadence. The same reasoning the maps-api ADR uses to keep maps out of commands-api applies here. |
Fold menu-api into effects-api |
effects-api dispatches discrete sensory events (sounds, particles) with no token registry, no command-tree reflection, and no Adventure ClickEvent plumbing. The Fabric obf/unobf carrier shape is the only thing the two share. |
| Grey-out (disabled fragment) inaccessible nodes | Forces a redeem-time predicate that duplicates the mint-time predicate. Carried forward from the prior draft; user direction (2026-05-15) confirms hide. |
| Resolve hover at redeem time | Adds DB / config I/O to the redeem hot path. Carried forward from the prior draft. |
Use commands-api onTabComplete output as the menu source |
Loses (child vs. parameter), (permission), (description) signal. Carried forward from the prior draft. |
Expose the builder as rtp-api SPI to addons in this ADR |
Premature; the SPI is now naturally menu-api-scoped. Addons will depend on menu-api directly once the module is extracted. |
Consequences¶
- Positive:
- One reflector + one token registry serve every present and future menu consumer (
/rtp config, region picker,/rtpadminwizards, addon-authored menus). Per-consumer work is aMenuConsumerProfile(≤ 30 lines). - Runtime command-tree updates (network-state changes, addon registration, reload) propagate automatically: the next menu open re-reflects the live tree (live-tree SSOT preserved).
menu-apiandcommands-apishare an explicit module boundary; the Brigadier coupling is visible in the dependency graph rather than buried inrtp-core.- The Fabric
ChatMenuRenderer(Stage 5.2) has a natural home (menu-api-fabric-unobf/) without dragging Fabric carriers intortp-api. - Addons that want to author menus depend on
menu-apialone, not the entire RTP-domain API. -
Future
*-apimodules cite the sibling DAG policy in §Module dependency layering instead of re-deriving it. -
Negative / Trade-offs:
- Module count grows by one (plus optionally
menu-api-fabric-unobf/). Mitigated: the addition follows the establishedeffects-api/commands-api/maps-apishape; no new build pattern is invented. - The migration must rewrite imports across
rtp-core,rtp-plugin,rtp-paper-common, and tests. Bounded scope: roughly the file set currently underrtp-api/.../menu/(10 files) plusrtp-core/.../commands/menu/(4 files) plusrtp-paper-common/.../menu/(1 file) and their test mirrors. - ADR cross-references must be reconciled. ADR-035 was committed-modified before this revision; it remains the menu-surface ADR and is updated by a one-line back-reference rather than renumbered or moved. ADR-044's own slot in the global sequence is retained (the module-extraction ADR replaces the reflector-only draft); no
menu-api-ADR-NNNre-sequencing is required for these two documents. - Stale hover text between mint and redeem under fast config edits. Carried forward from the prior draft.
Migration / Rollout¶
This ADR ratifies the module-extraction step, not the initial surface ship. The implementation lives in rtp-api/rtp-core as Stages 1–4 of CHECKLIST-generalized-menu.md. The checklist's amended Stage 6 captures the extraction work itself, gated on:
- Stage 5.3 (paginated chat renderers on Paper/Folia/Spigot) complete — minimizes mid-stage import churn.
- Stage 5.2 go/no-go for the Fabric obf/unobf carrier confirmed — drives whether
menu-api-fabric-unobf/is created in the same extraction or deferred. - D-005 proposal accepted — module additions cross module boundaries by definition.
When triggered, Stage 6 performs the following in a single dedicated change (no behavioral edits mixed in):
- Create
menu-api/withsrc/main/java/io/github/dailystruggle/rtp/menuapi/...andsrc/test/java/.... Addmenu-api/build.gradlemirroringcommands-api/build.gradle's structure (no platform deps,apiscope oncommands-api). - Move the 10 files currently under
rtp-api/src/main/java/io/github/dailystruggle/rtp/api/menu/intomenu-api/src/main/java/io/github/dailystruggle/rtp/menuapi/. Move their test mirror. - Move
LocalMenuTokenRegistry.javaandCommandTreeMenuBuilder.javafromrtp-core/.../commands/menu/intomenu-api/.../menuapi/build/and.../menuapi/token/. LeaveMenuRedeemSubcommand.javaandConfigMenuConsumerProfile.javainrtp-core(they bind to the live/rtproot and the YAML substrate, bothrtp-coreconcerns). - Rewrite imports across
rtp-api,rtp-core,rtp-plugin,rtp-paper-common, and tests. - Add
menu-apitosettings.gradle; addcompileOnly/apidependency onmenu-apiinrtp-api/build.gradle(and consequently transitively to all RTP consumers); add explicitmenu-apidependency inrtp-core/build.gradleandplatforms/rtp-paper/rtp-paper-common/build.gradlefor IDE clarity. - Add
REQ-RTP-MENU-001..004rows todocs/dev/REQUIREMENTS.mdand matchingTRACEABILITY.mdrows pointing atMenuStageTwoTest/BookMenuRendererTest(and any new Stage 5 renderer tests). - Add a row to
docs/adr/README.md's Subproject ADRs table ifmenu-api/docs/adr/is created (the menu-api ADR sequence starts atmenu-api-ADR-001only when the first menu-api-scoped decision after extraction is recorded; this ADR keeps its globalADR-044slot). - Add
CHANGELOG.mdbullet under[3.0.0-beta.3] - Unreleased ### Changed:menu surface moved to new \menu-api` sibling module; no API-level renames`. - Run
.\gradlew build(full multi-module) and cite the headline in the submit summary.
The user-visible delta from v3.0.0-beta.1 is the new module path; no class or method renames cross the boundary. Existing addons that depended on io.github.dailystruggle.rtp.api.menu.* will require a recompile against the new io.github.dailystruggle.rtp.menuapi.* package — listed under CHANGELOG.md "Breaking (compile-only) Changes" for the same release.
References¶
- ADR-011 —
rtp-apilayering; the 2026-05-15 clarification that sibling*-apimodules may depend on each other is recorded here. - ADR-035 — Interactive menus via written book. Defines the model, registry, and click-handling contract. This ADR is the module-placement decision for ADR-035's subjects.
- ADR-042 — Block-comment preservation. Source of truth for parameter hover text.
- ADR-046 —
maps-apimodule. Structural precedent for the three-layer SPI shape and the sibling-DAG dependency policy used here. - effects-api-ADR-006 — obf/unobf carrier dispatch contract reused for Fabric
ChatMenuRenderer. - rtp-fabric-ADR-009 — Fabric carrier split rationale.
- commands-api-ADR-001 — Brigadier bridge;
/rtp menuregistration path. - CONFIG_COMMAND_SPEC §2.4 —
viewsub-form; the/rtp configconsumer mirrors its hover / click-suggest contract. - REQUIREMENTS.md §3 — Prohibitions; the menu subsystem inherits S-004, S-005, S-006, S-007.
- TRACEABILITY.md — REQ-RTP-F-013 row covers menu chrome strings; REQ-RTP-MENU-001..004 rows added during Stage 6.
docs/dev/scratch/CHECKLIST-generalized-menu.md— multi-session implementation plan, including the Stage 6 module-extraction sub-plan ratified by this ADR.