Back to Rocket Chat

ADR 0001 — Accessor logic lives in the base-runtime, not the host

docs/adr/0001-app-accessor-logic-in-base-runtime.md

8.8.035.2 KB
Original Source

ADR 0001 — Accessor logic lives in the base-runtime, not the host

Status

Accepted — implemented. All phases landed. Supersedes the apps-accessor-consolidation proposal.

  • Date: 2026-07
  • Scope: packages/apps (host) and packages/apps/base-runtime (subprocess runtime)

Decision

  1. The base-runtime is the single source of truth for accessor behavior.
  2. The accessor:* message category — and handleAccessorMessage with it — is eliminated; bridges:* is the only host-bound RPC category originated by accessors.
  3. Registration accessors, whose side effects had no bridge do*, ride bridges:* through one new internal host bridge, AppResourceBridge.

Drift rule. Where the runtime and host implementations had diverged: merge non-conflicting logic; on genuine conflict the runtime supersedes the host. The runtime implementations are what apps actually experienced in production (host copies were unreachable from the subprocess). Two refinements:

  • Direction-aware. For MOVE accessors the host was authoritative (the app hit it via the proxy), so merging preserves live behavior. For RECONCILE accessors the runtime was authoritative, so folding in host-only logic adds observable behavior — a deliberate, tested change, never a neutral reconciliation.
  • Drift is classified before adoption. Runtime-is-superset → adopt silently. Runtime dropped or changed observable behavior → requires a justification that the drop is intentional plus a test pinning the resulting shape, so the migration cannot immortalize an accidental regression.

End state

BaseRuntimeSubprocessController handles exactly one app-originated RPC category — bridges:* — with a single dispatcher and a single permission model. The @rocket.chat/apps host contains no accessor implementation and no accessor manager (src/server/accessors/ and AppAccessorManager are deleted); the only accessor-shaped host code is the bridge layer (AppBridges/do*) plus AppResourceBridge. Every accessor an app uses is built and resolved inside packages/apps/base-runtime.

Follow-ups

Out of scope here; unblocked or motivated by this work.

  1. Bridge-level input hardening — move load-bearing caps into the bridge do* wrappers (the real trust boundary). Caps like getMessages ≤ 100 and removeUsersFromRoom ≤ 50 were already bypassable before this migration: app code runs via new Function in the runtime's own JS realm (globals are shadowed, not a security boundary — see construct.ts) and can emit arbitrary bridges:* messages. This work introduced no new exposure, it only made the advisory-ness visible. Independent security item, non-blocking.

  2. Replace the restart registration guard with an explicit reinitialize request so the subprocess knows not to re-send registrations, removing controller-state-dependent message dropping.

  3. messenger.sendRequest timeout — the runtime-side TODO is more prominent now that all accessor traffic flows through it.

  4. createProcessorId suffix check (SchedulerModify) — job-id namespacing uses includes (substring) instead of endsWith (suffix), so an appId substring in the middle of a job id makes it skip the suffix, risking collisions and jobs cancelJob can't reach. Flagged in review but not fixed inside the migration: the port was a faithful copy of the host code, and fixing only the runtime copy would have re-introduced the drift this ADR removes. The host copy is gone now, so it can land standalone with a test.

  5. Consolidated host↔subprocess protocol/SDK — one typed manifest of every host-bound method and its accepted params (the APP_ID bucket table is the seed), replacing hand-rolled message strings and per-param normalization judgment. Also where bucket C's nested-identity gap gets closed (host substitutes nested appId fields, or HttpBridge.doCall ignores the payload appId and uses the connection-known id).

  6. Deferred review findings — reviewers flagged the items below on ported accessors; in every case the port was byte-identical to its host original, so the flag is pre-existing behavior, not a regression. Deferred for the same reason as 4, and likewise unblocked now.

    Latent behavior (identical in host):

    • UploadCreator.uploadBuffer uses Object.hasOwn(descriptor, 'user') to decide whether to fetch the app user; with { user: null } (nullable per IUploadDescriptor) and no visitor token it treats the user as present and sends userId: undefined. A value check (!descriptor.user) would fall back to the app user.
    • RoomRead.getMessages accepts 0/negative limit — the guard only rejects > 100, unlike the 1–100 checks in getAllRooms/getUnreadByUser.
    • RoomRead.getMessages mutates the caller's options in place (options.limit ??= 100, options.showThreadMessages ??= true). Fix = copy before defaulting.
    • ServerSettingRead.getOneById casts the bridge result to ISetting with no null guard, so a missing setting returns null/undefined despite the typed return; sibling getValueById throws.
    • UIController's deprecated surface APIs (openModalView, updateModalView, openContextualBarView, updateContextualBarView) call the serializers directly, skipping the UIHelper.assignIds block-ID scoping that openSurfaceView/updateSurfaceView apply, so legacy interactions can emit un-scoped block IDs.

    Type/cosmetic (no runtime change): MessageRead.getSenderUser/getRoom cast the bridge result (as IMessage) and declare Promise<IUser>/Promise<IRoom>, so the undefined they return for a missing message — and which IMessageRead does declare — is absent from their own signatures.

Context

Accessors used to have two implementations. The subprocess got a thin proxy layer that forwarded accessor:* JSON-RPC messages to the host, where handleAccessorMessage walked the real accessor objects built by AppAccessorManager from src/server/accessors/. That split existed because, when the Deno runtime was introduced, module-resolution conflicts made sharing code between subprocess and host impractical. The constraint no longer held: base-runtime assumes Node module resolution, is compiled by tsc, and is consumed by Deno as TypeScript source through an import map.

Three call paths coexisted:

PathExampleMechanism
Pure proxyread.getRoomReader().getById(id)proxify() sends accessor:getReader:getRoomReader:getById; host resolves it via handleAccessorMessageAppAccessorManagerRoomBridge.doGetById
Local + direct bridge callmodify.getCreator().finish(builder)runtime validates locally, then sends bridges:getMessageBridge:doCreate to the permission-wrapped bridge method
Fully localconfigurationExtend.http.provideDefaultHeader()state lives entirely in the subprocess

Paths 2 and 3 were already the target, so the work was migrating path 1 into them and closing the gaps that forced accessors to stay on path 1.

Why bridges:* is sufficient security-wise. Permission enforcement never lived in the accessors; it lives in the host bridge do* wrappers (MessageBridge.doCreate checks AppPermissionManager.hasPermission(appId, AppPermissions.message.write)). Three properties make that channel a complete trust boundary: handleBridgeMessage only dispatches do-prefixed methods; any param equal to the literal 'APP_ID' is replaced host-side with the app's real id, so a subprocess cannot impersonate another app; and hasPermission(appId, …) takes the appId explicitly, with no call-stack or async-context magic that breaks when the caller leaves the host process. So moving accessor logic into the subprocess loses nothing permission-wise, provided every side effect still flows through a do* method. The corollary: accessor-level validation is advisory inside the sandbox — but it already was, since a subprocess can emit arbitrary bridges:* messages (follow-up 1).

Who consumed the host accessors. Almost exclusively the RPC channel. The only load-bearing non-RPC use was AppListenerManager.executePostMessageSent calling getReader(appId).getUserReader().getAppUser() to gate IPostMessageSentToBot. accessors/Http.ts reached back into AppAccessorManager for HTTP pre/post handlers (dead for subprocess apps); accessors/AppAccessors.ts was never instantiated; and AppApiManager, AppSlashCommandManager, AppVideoConfProviderManager, AppOutboundCommunicationProviderManager threaded this.accessors into executors that ignored it (_accessors) — legacy in-process-era signatures. Nothing in apps/meteor touched AppAccessorManager.

Architecture

AppResourceBridge

Every bridge gap reduces to one capability: invoke an app-scoped operation on a host manager or the app's own storage item. One internal bridge lets them ride the existing permission-checked, appId-substituting bridges:* channel:

text
AppResourceBridge (host-side, internal — not part of the app-facing definition surface)
├── doProvideSetting(setting, appId)              → ProxiedApp.getStorageItem() mutation (SettingsExtend)
├── doGetAppSetting(id, appId)                    → app.getStorageItem().settings[id] (SettingRead)
├── doUpdateAppSetting(setting, appId)            → AppSettingsManager.updateAppSetting
├── doProvideSlashCommand(command, appId)         → AppSlashCommandManager.addCommand
├── doModifySlashCommand(command, appId)          → AppSlashCommandManager.modifyCommand
├── doEnableSlashCommand(name, appId)             → AppSlashCommandManager.enableCommand
├── doDisableSlashCommand(name, appId)            → AppSlashCommandManager.disableCommand
├── doProvideApi(api, appId)                      → AppApiManager.addApi
├── doListApis(appId)                             → AppApiManager.listApis
├── doRegisterProcessors(processors, appId)       → AppSchedulerManager.registerProcessors
├── doRegisterActionButton(button, appId)         → UIActionButtonManager.registerActionButton
├── doRegisterExternalComponent(component, appId) → AppExternalComponentManager.addExternalComponent
├── doProvideVideoConfProvider(provider, appId)   → AppVideoConfProviderManager.addProvider
└── doRegisterOutboundProvider(provider, appId)   → AppOutboundCommunicationProviderManager.addProvider
  • Placement and ownership. The managers live in packages/apps, so the bridge does too. It is not an abstract getter on AppBridges — that would force every orchestrator/host embedding to implement it. handleBridgeMessage resolves names from this.bridges first, then from a dedicated getAppResourceBridge lookup. The bridge is stateless (every method takes the appId and delegates to an AppManager-owned manager), so AppManager constructs it once (getAppResourceBridge()) and every BaseRuntimeSubprocessController shares that instance. The do* gate and 'APP_ID' substitution apply identically.
  • Permission and error behavior preserved, including throw-vs-silent. Each method calls the same manager the host accessor used: AppVideoConfProviderManager/AppOutboundCommunicationProviderManager addProvider still throw PermissionDeniedError, UIActionButtonManager.registerActionButton still logs-and-refuses silently, and conflict errors (CommandAlreadyExistsError, CommandHasAlreadyBeenTouchedError, PathAlreadyExistsError, VideoConfProviderAlreadyExistsError) still propagate as JSON-RPC errors.
  • The restart guard moved with it — via an explicit method set, not a name prefix. handleAccessorMessage used to short-circuit any getConfigurationExtend call to success(null) while the controller is restarting (re-running app:initialize must not re-register resources, but the subprocess must still rebuild its local AppObjectRegistry entries). That guard keyed on the accessor origin, structurally capturing the whole registration surface. The bridge version deliberately does not re-derive the boundary from doProvide*/doRegister* prefixes — a future registration method named otherwise would double-register during restart, and a future non-registration method matching the prefix would be silently dropped. Instead a static AppResourceBridge.REGISTRATION_METHODS set enumerates exactly the suppressed names (registrations only — not doGetAppSetting/doUpdateAppSetting/doModifySlashCommand/doEnable*/doDisable*/ doListApis), consulted by handleBridgeMessage while state === 'restarting'. Runtime _proxy wrappers still stash live instances into AppObjectRegistry before the RPC, so local re-registration works. Stopgap; follow-up 2 is the real fix.

bridgeCall

Runtime accessors do not hand-roll message strings. base-runtime/src/lib/bridges/bridgeCall.ts exposes one helper:

ts
bridgeCall<T>(senderFn, bridge: BridgeName, method: `do${string}`, ...params: unknown[]): Promise<T>

It encodes bridges:<bridge>:<method>, unwraps response.result, and normalizes rejections through formatErrorResponse. BridgeName is the union of host bridges reachable from the subprocess (including getAppResourceBridge); the `do${string}` type mirrors the host gate. Accessors get a (request) => this.senderFn(request) thunk so instance-level senderFn test stubs stay intercepted. (An earlier iteration used a RemoteBridges proxy facade with a getter per bridge; bridgeCall replaced it with the same guarantees and no Proxy layer.)

It deliberately does not auto-inject 'APP_ID'. Identity is passed explicitly and positionally by each accessor, exactly as the host accessor did, so a faithful copy keeps caller-identity params carrying the sentinel and leaves app-supplied argument-appIds raw by construction. Centralizing injection would be cleaner but would make the argument-appId exceptions the dangerous default.

APP_ID sentinel everywhere

All bridge params denoting the calling app use the 'APP_ID' literal, substituted host-side. This closed the pre-existing inconsistency (drift 7) and removed the runtime's reliance on AppObjectRegistry.get('id') for identity — it stays available for non-identity uses like UIHelper.assignIds block-id prefixes and scheduler job-id suffixes.

This is a per-param judgment that fails in both directions, caught by neither the type-checker nor a green suite: over-normalizing a genuine app-supplied argument-appId silently strips the app's ability to pass any appId; under-normalizing a caller-identity param re-opens the impersonation gap. So every do* appId param was audited into exactly one of three buckets — only A is normalized:

BucketParamsValue sent
A — caller identity, top-level positionalThe trailing appId of every Notifier, ModifyCreator, ModifyUpdater, ModifyExtender, reader, and modifier bridge call (doNotifyUser, doNotifyRoom, doTyping, doGetAppUser, doCreate*, doGetById, doUpdate, …), and everything not listed in B/C'APP_ID' sentinel
B — app-supplied argument appIdModerationBridge.doReport, .doDismissReportsByMessageId, .doDismissReportsByUserId (ModerationModify ignores its constructor _appId and forwards the caller's arg); UserBridge.doDeleteUsersCreatedByApp (the id is the target app that created bot users); UserBridge.doGetAppUser (legacy param, removing it risks a break)raw caller value
C — nested identity fieldHttpBridge.doCall's single payload param { appId, method, url, request }appId is a field, and the host substitution maps only top-level positional params, so the sentinel would travel through unrewrittenresolved AppObjectRegistry.get('id')

Before this work every site sent the raw resolved id except ModifyCreator._finishMessage's doGetAppUser — the lone correct one, and the drift that motivated the normalization. Host-observable behavior is unchanged (the host still ends up with the real id); the wire now carries the sentinel and the impersonation gap is closed for buckets A and B. Audit rule when adding a do* call: an appId populated from a method parameter rather than the app's own identity belongs in bucket B.

Bucket C is a pre-existing impersonation surface predating this migration (the sandbox picks the id it sends), so this work introduced no new exposure there; closing it needs a host change — either substituting nested appId fields for known payload params, or having HttpBridge.doCall ignore the payload appId and use the connection-known id (follow-up 5).

Module-resolution constraints (the original reason for the proxies)

The Deno subprocess consumes base-runtime as TypeScript source through the import map (@rocket.chat/apps/base-runtime/../base-runtime/src/); compiled dist CJS is avoided because its require() calls bypass the import map. So moved code may import only @rocket.chat/apps-engine/definition/* (mapped to source), node: builtins, and npm deps already in deno.jsonc's import map or Deno cache — which every moved accessor satisfies.

UIHelper was duplicated into base-runtime rather than shared. It is a small pure helper (only node:crypto plus two type-only imports) needed by ModifyCreator/ModifyUpdater/UIController, and the runtime used to import it from @rocket.chat/apps/dist/server/misc/UIHelper — exactly the fragile dist-CJS path the runtime avoids. Having the host import it back from base-runtime was rejected: base-runtime already imports apps/dist, so that would create an unlinearizable build cycle (flipping the order only moves it). Phase 0 copied it into base-runtime/src/lib/ and left the host copy for the still-living host accessors; since all three src/ importers were MOVE accessors, that copy died in teardown — single source of truth at teardown, no cross-package import, no build-order change. Other small carry-overs: createProcessorId (a local function) and RoomRead's GetMessagesSortableFields sort validation (constants from apps-engine definitions). lodash.clonedeep was not needed — the runtime extenders already exist without it.

Error semantics

Validation errors formerly thrown host-side inside accessor methods (serialized as JSON-RPC error 1000) now throw locally in the subprocess: same message, better stack traces, one less round trip. Bridge-side errors keep the handleBridgeMessage shape (code -32000, message preserved, formatted by formatErrorResponse). Permission failures on read/write bridges stay silent-undefined; manager-level registration failures keep throwing. Both are preserved verbatim because both layers are reused untouched.

AppObjectRegistry and one-app-per-process

The registry (a process-global singleton keyed by simple strings, including a single id) assumes one app per subprocess. The migration keeps that assumption, restated in code comments since consolidating logic into the runtime makes it more load-bearing.

Per-accessor disposition

MOVE — implement in base-runtime, side effects via existing bridge do*, delete host class. RECONCILE — already in base-runtime; make canonical, delete host copy. BRIDGE-GAP — needs AppResourceBridge. DELETE — already dead on the host.

Readers (all MOVE — bridge passthroughs plus portable validation). Reader (facade over 15 sub-readers); MessageRead (getSenderUser/getRoom are fetch-then-extract compositions); RoomRead (getMessages/getAllRooms/getUnreadByUser limit/skip/sort validation); UserRead (getAppUser(appId?) arg defaulting); PersistenceRead (single-assoc → array wrapping); LivechatRead (isOnline deprecation warning — runtime console is piped to app logs); UploadRead (getBufferById becomes two bridge RPCs); CloudWorkspaceRead, VideoConferenceRead, OAuthAppsReader (doGetByid typo is on the bridge, unchanged), ContactRead, ThreadRead, RoleRead (1:1); ExperimentalRead (empty shell → empty local class).

Environment. MOVE: EnvironmentRead/EnvironmentWrite (facades), ServerSettingRead (getValueById value → packageValue fallback; getAll keeps throwing "not implemented" locally), EnvironmentalVariableRead, ServerSettingUpdater (+ incrementValue default). BRIDGE-GAP: SettingRead (reads ProxiedApp.getStorageItem().settings) → doGetAppSetting; SettingUpdater (AppSettingsManager.updateAppSetting persists, fires doOnAppSettingsChange, runs the ON_PRE_SETTING_UPDATE round-trip back into the app) → doUpdateAppSetting.

Modify. RECONCILE: ModifyCreator, ModifyUpdater (diff-update semantics), ModifyExtender, Notifier (awaits typing calls). MOVE: Modify (facade); LivechatCreator (createToken already local in the runtime, supersedes; rest 1:1); UploadCreator (uploadBuffer defaults user via doGetAppUser — two RPCs); EmailCreator, ContactCreator, MessageUpdater, OAuthAppsModify (1:1); LivechatUpdater (+ boolean coercion in setCustomFields); UserUpdater (partial-object wrapping); ModifyDeleter (removeUsersFromRoom ≤ 50 validation); UIController (UIKit interaction payloads + UIHelper.assignIds, then doNotifyUser; required relocating UIHelper); SchedulerModify (createProcessorId is pure and idempotent — the subprocess knows its own appId for the suffix and the bridge still permission-checks the real id); ModerationModify (appId comes from method args by design).

Configuration extend/modify — the registration surface. These are why handleAccessorMessage could not be deleted by bridge calls alone: they write into host-only managers whose in-memory registries the host reads to render UI, route events, and dispatch executions back into the subprocess. MOVE: ConfigurationExtend, ConfigurationModify (facades), ServerSettingsModify (1:1 + increment default). RECONCILE: HttpExtend (already fully local). BRIDGE-GAP: SettingsExtenddoProvideSetting; SlashCommandsExtend (registry, touched-command conflict tracking, CommandAlreadyExistsError) → doProvideSlashCommand; ApiExtend (registry, PathAlreadyExistsError) → doProvideApi plus listApisdoListApis, killing the accessor:api:listApis special case; ExternalComponentsExtenddoRegisterExternalComponent; SchedulerExtend (wraps processors in host callbacks, namespaces ids, guards disabled apps) → doRegisterProcessors; UIExtend (button registry read directly by host UI, ui.registerButtons permission, doActionsChanged side effect) → doRegisterActionButton; VideoConfProviderExtend (PermissionDeniedError, name-collision tracking) → doProvideVideoConfProvider; OutboundMessageProviderExtenddoRegisterOutboundProvider; SlashCommandsModifydoModifySlashCommand/doEnableSlashCommand/doDisableSlashCommand.

Serialization note: ISlashCommand, IProcessor, IApi, and the provider objects contain executor functions. The old accessor:* messages already stripped them in JSON serialization — the host managers keep metadata and dispatch executions back into the subprocess, which looks the live instances up in AppObjectRegistry. The new bridge methods receive the same serialized shapes, so the wire semantics did not change, only the message prefix.

Standalone. Http — RECONCILE (runtime version already merges HttpExtend defaults, runs pre/post handlers, calls doCall; the host copy and its reach-back into AppAccessorManager are deleted). Persistence — MOVE (1:1 + arg wrapping/defaults). AppAccessors — DELETE (never instantiated on the host; the runtime one in mod.ts is live).

Drift resolutions

Only the bounded subset carrying logic on both sides — the RECONCILE accessors plus the few MOVE accessors already partially local. Pure-proxy accessors had no runtime counterpart to reconcile; they were faithful-ported and covered by the parity check instead.

Classes: superset/equiv — runtime already covers the host behavior, adopt silently. conflict→runtime — genuine divergence; runtime wins, observationally neutral since the runtime was authoritative. merge (host→runtime) — host-only logic folded in, adds observable behavior, ships with a test (no row needed one).

#DriftClassDecision
1Update payload shape — host ModifyUpdater._finishMessage/_finishRoom sent the full builder object to doUpdate; runtime sends a diff ({ id, ...builder.getChanges() }).conflict→runtimeDiff wins; bridges demonstrably accept it (production sends it). Host path deleted, diff shape pinned by a test.
2Editor tracking — runtime ModifyUpdater.message() calls builder.setEditor(editor); host ignored the updater param.superset/equivRuntime wins; editor recorded on update.
3typing() awaits — runtime Notifier awaits doTyping; host fire-and-forget.superset/equivRuntime wins.
4createToken — runtime generates randomBytes(16).toString('hex') locally; host generated it host-side.conflict→runtimeRuntime wins; same format.
5HTTP method representation — runtime uses lowercase literals; host used the RequestMethod enum.superset/equivRuntime wins; doCall payload identical.
6BlockBuilder appId — host getBlockBuilder() passed appId into the builder; runtime builder takes none (ids assigned later by UIHelper.assignIds).conflict→runtimeRuntime wins; the drop is intentional (ids assigned later) and the block shape is pinned by a test.
7APP_ID inconsistency — exactly one runtime call site (ModifyCreator._finishMessagedoGetAppUser) sent the sentinel; every other sent the raw AppObjectRegistry.get('id').normalizationNot a pick-a-side drift; all caller-identity params now use 'APP_ID', argument appIds stay raw.
8Cosmetic error wording ("can not" vs "can't").conflict→runtimeRuntime wording wins.

Where a ported accessor interacts with a reconciled one, the reconciled (runtime) semantics apply.

Alternatives considered

  • Move the registries into the subprocess — non-starter. The host reads UIActionButtonManager.getAllActionButtons to render UI, routes slash-command/api/scheduler/provider invocations off these registries, and enforces cross-app conflict rules (two apps claiming the same slash command) that no single subprocess can arbitrate; app settings are host-persisted metadata read by other subsystems. A host-side write surface must remain.
  • Keep a residual accessor:* channel just for registration — works, but keeps two RPC categories, two dispatchers, and the reflective accessor-path-walking code alive. The bridge approach reuses an existing dispatcher, its security gates, and its error model, and let handleAccessorMessage, ALLOWED_ACCESSOR_METHODS, isValidOrigin, and getAccessorForOrigin be deleted entirely.

Consequences

Positive. One source of truth for accessor behavior; the host accessor layer and AppAccessorManager deleted; subprocess controllers lose the whole accessor:* family and its dispatcher; working LSP navigation into real implementations instead of proxies; local validation throws with real stack traces and one less round trip.

Accepted trade-offs. Slightly chattier composite reads — UploadRead.getBufferById and UploadCreator.uploadBuffer became two bridge RPCs where one accessor RPC (wrapping two in-host bridge calls) sufficed; bounded, rare, symmetrical with what Notifier/ModifyCreator already did, and no pagination-style N+1 patterns exist in this surface. Sandbox validation is advisory (unchanged in practice, now explicit — follow-up 1). Two implementations existed transiently during rollout, mitigated by per-accessor merge units and the parity harness.

"Faithful port" was verified, not asserted. For MOVE accessors the host logic was live in production, so a subtly wrong port — a dropped default, an off-by-one cap, a renamed sort field — is an immediate observable regression. Ported unit tests only prove the new class satisfies carried-over assertions; they do not prove equivalence, so a port that drops an uncovered branch passes green. Each host MOVE class deletion was gated on a mechanical parity check: either a differential harness driving representative calls through both paths and asserting identical bridge-message output (method string + params) and return shaping, or an enforced branch-coverage audit of the ported tests against the host source. RECONCILE accessors did not need it (their host copy was already unreachable); the drift rules govern them instead. The harness (base-runtime/src/lib/accessors/tests/helpers/parityHarness.ts) was planned as throwaway but kept — it became a test utility the base-runtime accessor suites depend on.

Implementation record

A phase was a milestone, not a single PR. Each phase is independently shippable and keeps test:node/test:deno/test:base-runtime green, but the unit of merge was the accessor: Phases 1–2 landed as a series of per-accessor (or tight cohesive-group) PRs, so blast radius per merge is one accessor family. This mattered because the migration flips live production behavior for MOVE accessors with no rollback but git revert, and the unit of revert equals the unit of merge. Host accessor tests were ported to base-runtime/src/lib/accessors/tests/.

Phase 0 — foundations, no behavior change. Added the typed bridge entry point (now bridgeCall.ts) with tests for message-string generation, the do* gate, and error formatting. Produced the APP_ID exception list — the audit surfaced bucket C, since the sentinel substitution rewrites only top-level positional params. Refactored the existing runtime accessors (Http, Notifier, ModifyCreator, ModifyUpdater, ModifyExtender, roomFactory) onto it and normalized caller-identity params; accessors still holding accessor:* sub-proxies kept senderFn for those until Phase 2. Duplicated UIHelper into base-runtime/src/lib/. Stood up the parity harness. No changeset: @rocket.chat/apps is private and has never carried one — this ADR and the exception list are the recorded contract.

Phase 1 — readers, Persistence, server-side environment. Ported the reader family plus ServerSettingRead, EnvironmentalVariableRead, ServerSettingUpdater, ServerSettingsModify, Persistence, and the Reader/EnvironmentRead/EnvironmentWrite facades, moving all portable validation/defaulting verbatim; app-settings members stayed proxied until Phase 3. Flipped the corresponding mod.ts entries to local. Host-class deletion was deferred to Phase 4 (deliberate deviation): AppAccessorManager.getReader() builds the whole reader family and was still called by AppListenerManager.executePostMessageSent and to build not-yet-migrated sub-accessors, so deleting e.g. RoomRead then would have pulled the Phase-4 refactor forward mid-phase. RPC-boundary adaptation: ServerSettingRead.getValueById checked typeof set === 'undefined' host-side; across the boundary an absent return arrives as null, so the runtime treats both as "not found" — identical for apps, since the bridge only ever returns a setting or nothing.

Phase 2 — modify family. Ported ModifyDeleter, MessageUpdater, LivechatUpdater, UserUpdater, LivechatCreator, UploadCreator, EmailCreator, ContactCreator, UIController, SchedulerModify, OAuthAppsModify, ModerationModify; caller-identity params use the sentinel, the ModerationModify/ModifyDeleter.deleteUsers argument-appIds stay raw (bucket B), and UIController/SchedulerModify read the real id from AppObjectRegistry for their non-identity uses. Removed the remaining proxify entries in getModifier and the accessor:* sub-creator/sub-updater proxies, after which getReader/getModifier/getPersistence/getHttp generated zero accessor:* traffic. RPC-boundary note: sub-accessor methods now flow through Promise<unknown> and are cast to their interface return types; void-returning methods await instead of returning the bridge value.

Phase 3 — registration surface. Added src/server/bridges/AppResourceBridge.ts and wired it into handleBridgeMessage via the dedicated getAppResourceBridge lookup plus the restarting guard keyed on REGISTRATION_METHODS; no apps/meteor orchestrator changes needed. Rewrote getConfigurationExtend, getConfigurationModify, and the app-settings SettingRead/SettingUpdater/SettingsExtend members onto getAppResourceBridge().do*, preserving the AppObjectRegistry stash-then-forward; accessor:api:listApis became doListApis; registerButton stays a synchronous void per its interface. The dead proxify machinery and WithProxy type were removed from mod.tsthe runtime stopped emitting accessor:* entirely. RPC-boundary note: SettingRead.getValueById treats null and undefined alike, same adaptation as ServerSettingRead.

Phase 4 — message-path teardown. Replaced the executePostMessageSent accessor call with this.manager.getBridges().getUserBridge().doGetAppUser(appId). Deleted handleAccessorMessage, ALLOWED_ACCESSOR_METHODS, isValidOrigin, getAccessorForOrigin, the accessor: branch in handleIncomingMessage, and the controller's unused accessors/api fields — the accessor:* category no longer exists; the controller dispatches only bridges:*, ready, log, and the error notifications. JSONRPC_METHOD_NOT_FOUND is kept (imported by ProxiedApp, AppListenerManager, AppVideoConfProvider). Removed the four now-meaningless handleAccessorMessage cases from DenoRuntimeSubprocessController.test.ts. Physical deletion of the dead classes was folded into Phase 5, since it first required un-threading the ignored accessors parameter from the sandbox-execution core — a separate behavior-neutral change that should not ride at the tail of the teardown.

Phase 5 — dead host accessor layer removal. Behavior-neutral; nothing on the subprocess path reached any of it after Phase 4. Un-threaded the vestigial accessors parameter from AppApi, AppSlashCommand, AppVideoConfProvider, and AppOutboundCommunicationProvider run*/runTheCode methods (which already ignored it as _accessors), updated the four managers' call sites, and dropped their unused accessors fields. Deleted AppAccessorManager, its managers/index.ts export, and AppManager's field, construction, getAccessorManager(), and the purifyApp(...) call in removeLocal (a no-op once no host code called getReader/getModifier). Deleted src/server/accessors/ (64 files) and the host src/server/misc/UIHelper.ts. Adapted tests: removed tests/server/accessors/ (35 suites, covered by the base-runtime ones), AppAccessorManager.test.ts, and the accessor setup/assertions in the four manager tests, AppSettingsManager.test.ts, AppManager.test.ts, AppListenerManager.test.ts, and the two Deno-runtime integration tests. Verification: host tsc error-set unchanged vs the environmental-only baseline; test:node host suites green (excluding the two deno-spawn integration suites needing a deno binary unavailable in that environment); base-runtime suite unaffected.