veratown-prototype

Map Position Observation Sharing — MVP Implementation Record

Status: Conservative MVP implemented 2026-10-19. Sharing remains disabled by default and production activation still requires the private-network setup and qualification gates below. Historical position persistence is deferred as a separate, non-prerequisite work item.

The implementation replaces the earlier mTLS transport recommendation with Railway’s private WireGuard network and built-in HTTP. Each helper uses a unique secret bearer token; the supervisor checks its enabled registry identity and room/runtime scope, and authenticates its stream response with an HMAC proof. No public route, TLS certificate, extra dependency, new BC socket, or position database was added. See the map/doors guide, helper guide, and Railway runbook for the shipped contracts and configuration.

1. Current problem and verified findings

Helper startup teleport dispatches ChatRoomCharacterMapDataUpdate, then waits for evidence of the helper’s destination. Missing evidence leaves the worker fenced with timed_out_unobserved; it must not start tasks or blindly retry.

What the reverted commit actually did

The commit changed only src/core/map-location-events.ts, adding interfaces and an unused MapPositionObservationBus. It added no publisher, composition-root instance, helper injection, or cross-process transport.

Before reverting, npm run check reproduced:

src/core/map-location-events.ts(45,12): error TS4114:
This member must have an 'override' modifier because it overrides a member
in the base class 'EventEmitter<MapPositionObservationEvents>'.

tsconfig.json enables noImplicitOverride; the new emit() override omitted override. This is a confirmed compilation/build regression, not proof of the reported production runtime crash. No production stack trace was supplied. Recent available GitHub Actions runs did not expose a corresponding failed job. Obtain the deployment’s first fatal diagnostic, image SHA, Node version, and startup/build logs before assigning a runtime cause.

Latent risks, not demonstrated crash causes: synchronous subscriber exceptions, mutable cached coordinates, unbounded entries without scheduled pruning, receive-time TTL extension, and no room/session isolation. Since the class was unwired, these risks do not explain an active event-handler crash in this commit.

2. Architecture and non-negotiable boundaries

flowchart LR
    BC[BC server] --> MainClient[Main BcClient]
    MainClient --> Doors[DoorFeaturePlugin]
    Doors --> MainBus[Main process position bus]
    Doors --> Locations[Existing MapLocationEventBus]
    MainBus --> Sender[Authenticated observation sender]
    Sender --> Receiver[Helper process observation receiver]
    Receiver --> HelperBus[Helper-local position bus]
    HelperClient[Helper BcClient] --> Teleport[MapTeleportAction]
    HelperBus --> Teleport
    Teleport --> Coordinator[Existing ActionCoordinator]
    Teleport --> HelperClient

An EventEmitter does not cross a process boundary. Each process gets one local bus. The production solution additionally needs an explicitly approved observation transport; sharing a JavaScript object or passing it to HelperBotsPlugin is insufficient for the deployed topology.

Implemented transport: a bounded authenticated private HTTP stream using Node’s existing node:http over Railway’s private WireGuard network. Railway’s private domain and service port are required in production; the supervisor must not expose a public domain. Each helper has a distinct random bearer token; the supervisor authenticates that token, checks the helper’s enabled database record and room/runtime/fleet scope, and signs the scoped handshake with an HMAC derived from the same secret. The receiver verifies this proof, stream epoch/sequence, room/runtime, member scope, frame bounds and five-second age. Test runtime may use loopback for qualification.

This transport has been implemented and exercised with separate loopback sessions, including heartbeat liveness, reconnect, token rejection, and scoped cache delivery. Production enablement still requires Railway private networking, secret provisioning, test-runtime qualification, and a one-helper canary. Sharing remains off unless explicitly enabled on both processes. There is no extra BC connection, whisper, Redis, or third-party dependency.

The transport carries transient live BC observations, never desired state, journal results, runtime/database heartbeats, or a database position cache. Its sequenced one-second stream heartbeat is a liveness control frame only and never creates or refreshes a position observation. MongoDB may store bounded historical movement records as described below, but persisted coordinates must never count as current BC evidence. Preserve one BC socket/send queue and one Mongo client per process.

Bounded movement history and inactive-character storage

Keep three distinct data views:

View Storage and retention Permitted use
Current observation One latest coordinate per member in the bounded live bus; five-second TTL, invalidated on departure/disconnect Fresh BC evidence for reconciliation
Active movement history At most 10 position visits per connected character, within an explicit active-character capacity Diagnostics only
Inactive movement history MongoDB only after departure; at most 10 visits and one last-known position per room/member Historical lookup, never live evidence

The reverted cache already replaced the previous position for each member; it did not retain every movement. Its potential growth was in distinct member keys without reliable pruning. A 10-visit limit bounds history per character, not the total number of characters. Bound both active-character count and persisted document count; do not keep disconnected characters in an in-memory archive.

A visit records coordinates, enteredAt (first observed there), lastSeenAt, and optional leftAt. These are observation times, not proof of the exact physical entry/exit time. A repeated sync at the same coordinate updates lastSeenAt without adding another visit. A changed coordinate closes the prior visit and appends the new one, dropping the oldest beyond 10. A departure or observer disconnect closes observation coverage with an explicit reason; it must not imply the character physically moved or left the room when only the observer lost its connection.

On departure, invalidate the live bus immediately, independently of database availability. Persist a bounded immutable history snapshot, including the last known position and disconnection/coverage-end time, then release active history. Use a bounded persistence queue and bounded retries; never retain unlimited departed histories while MongoDB is unavailable. On queue overflow or exhausted retries, release memory and report a sanitized failure counter. History is best-effort diagnostics, not a reason to stall BC processing or weaken fencing.

Proposed collection: prototypeMapPositionHistory, owned by a map feature repository injected from src/app/start.ts using the existing guarded Db. Use one document per (roomKey, memberNumber) in the selected runtime database: production only veratown_prototype, test only veratown_prototype_test. Never add this runtime history to the legacy-source copy allowlist.

Suggested document fields:

Create a unique { roomKey: 1, memberNumber: 1 } index and a TTL { expiresAt: 1 } index with expireAfterSeconds: 0; back any oldest-first cleanup query with an index. Proposed historical retention is 30 days after the last genuine observation, subject to operator approval, with an additional 10,000-document collection cap enforced by bounded oldest-first cleanup. Database TTL deletion is asynchronous: reads must filter expired records, and the cap is an operational retention bound, not an instantaneous MongoDB quota. Use bounded writes and monitor cleanup failures. Neither reads nor retries extend retention. The last-known position survives disconnects and process restarts until retention expires; indefinite retention is not proposed.

Only the main observer writes this history; helpers do not duplicate it. Serialize per-member snapshots and atomically reject older revisions, including delayed writes from prior observer epochs. Define an epoch-fencing mechanism at the repository boundary before implementation; do not order random epoch IDs lexicographically or compare only wall-clock timestamps. Validate documents with existing Zod contracts. Store only necessary identifiers, coordinates and times, never chat, appearance, private map state, credentials or raw packets.

On reconnect, start a new live observation generation. Historical records may be fetched for an explicitly historical diagnostic view, but must never seed the live bus, renew freshness, confirm teleport completion, authorize actions, or bypass worker fencing. Keep historical lookups paginated/bounded and do not preload inactive records into memory.

Implement this as a separate, reviewable history work item alongside phases 2/4, not a prerequisite for live observation delivery. Add repository ownership, collection/retention documentation, cleanup lifecycle and drain deadlines to the implementation PR. Preserve shutdown order: drain bounded history writes during plugin stop before closing MongoDB last.

Ownership and integration contracts

Component Proposed responsibility
src/core/map-position-observations.ts Transport-neutral observation type, typed local bus, read-only subscription/query port
src/core/map-location-events.ts Existing location transitions only; no changes needed for the future bus
src/features/map/plugin.ts Publish validated main-client position observations independently of door-rule processing
src/features/map/actions/map-position-channel.ts Combine shared and local evidence into the existing ObservationChannel contract
src/features/map/actions/map-teleport-action.ts Keep serialized teleports, one coordinator call and guarded updateOwnMapPosition
src/adapters/observation/ Authenticated stream codec/client/server; depends on core, never features
src/features/helper-bots/plugin.ts Own injected observation-session lifecycle and existing worker fencing, not other feature plugins
src/app/start.ts Construct each bus, transport and action; inject capabilities; preserve static plugin order

The new core types use structural { X, Y } coordinates, not imports from the BC adapter. Neither map nor helper features should import a new service from each other. Composition passes the shared capability. Keep raw positions distinct from locationEntered/locationLeft; derive no door unlock or task completion directly from a position-bus event.

Cross-process safety contract (release prerequisite)

The implemented NDJSON stream uses a scoped hello frame followed by position, invalidate, ready, and heartbeat frames. The authenticated hello contains:

interface PositionObservationEnvelope {
    readonly version: 1;
    readonly runtimeTarget: "production" | "test";
    readonly roomKey: string;
    readonly roomName: string;
    readonly observerId: "supervisor";
    readonly observerEpoch: string;
    readonly memberNumber: number;
    readonly proof: string; // HMAC-SHA256, base64url
}

Required receiver behavior:

  1. Authenticate the helper with its unique bearer token and configured agent ID. The supervisor verifies the helper is enabled in the database for the exact member, room, and runtime, and within the configured fleet limit. The hello’s HMAC proof authenticates the supervisor response. Never trust the agent ID or observer ID from request/frame text alone. Helpers cannot publish observations as the supervisor or subscribe to unrelated members.
  2. Validate unknown input at the adapter boundary with existing Zod: strict version/source enums, bounded strings, positive safe-integer member/sequence, finite timestamps, and integer coordinates within the current 40×40 map. Limit each frame to 4 KiB; reject oversized/invalid frames without logging them.
  3. Bind streams to a supervisor process epoch and monotonically increasing per-helper sequence. A reconnect in the same process preserves its sequence; a new process uses a fresh epoch. Reject duplicate/decreasing sequence values, old stream callbacks, wrong room/runtime, and replayed prior epochs. Establish the current epoch through the authenticated handshake, not any arbitrary incoming observation. Clear receiver caches on stream loss.
  4. Retain original observedAt. Enforce application expiry at five seconds; receiving, querying, polling or replaying never renews it. Require synchronized deployment clocks; reject future observations and fail closed on clock skew. Also enforce a receiver-side monotonic five-second upper bound, so a backward wall-clock adjustment cannot extend acceptance.
  5. Initial replay may include only unexpired observations from the active epoch. Send invalidation frames on character departure, missing position in a full roster, room change, supervisor disconnect and reconnect. Disconnect/epoch invalidation must clear remote entries before new observations are accepted.
  6. Bound queues and subscriptions; coalesce unsent positions per member, preserve ordering, and close slow consumers rather than silently retaining stale evidence. Networking never blocks BC event processing.

The following local examples record the core and feature integration shape. The transport implementation adds the authenticated HTTP stream, strict frame validation and lifecycle behavior described above.

3. Implemented — contracts and a safe local bus

Create a separate core module. Use publish(), not an override of emit(); this avoids the exact compiler regression and separates validation from notification. Consumers receive only PositionObservations, never a writable emitter. The shipped bus uses subscriber sets (rather than extending EventEmitter), invokes each callback behind a sync/async error boundary, keeps one frozen coordinate per member, and exposes the publisher capability only to the supervisor plug-in/session. Its capacity is 512 and expiry is checked on reads and writes using a monotonic-derived epoch clock.

import { EventEmitter } from "node:events";

export interface MapPositionObservation {
    readonly memberNumber: number;
    readonly position: Readonly<{ X: number; Y: number }>;
    readonly observedAt: number;
    readonly source:
        | "ChatRoomSyncMapData"
        | "ChatRoomSync"
        | "ChatRoomSyncMemberJoin"
        | "ChatRoomSyncCharacter"
        | "ChatRoomSyncSingle";
}

const positionSources: ReadonlySet<string> = new Set([
    "ChatRoomSyncMapData",
    "ChatRoomSync",
    "ChatRoomSyncMemberJoin",
    "ChatRoomSyncCharacter",
    "ChatRoomSyncSingle",
]);

interface PositionEvents {
    positionObserved: [observation: MapPositionObservation];
}

export interface PositionObservations {
    getLatestObservation(memberNumber: number): MapPositionObservation | undefined;
    subscribe(listener: (value: MapPositionObservation) => void | Promise<void>): () => void;
}

export class MapPositionObservationBus
    extends EventEmitter<PositionEvents>
    implements PositionObservations
{
    private readonly cache = new Map<number, MapPositionObservation>();

    public constructor(
        private readonly now: () => number,
        private readonly reportFailure: () => void,
        private readonly maxEntries = 512,
    ) {
        super();
        if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
            throw new Error("Invalid position cache bound");
        }
    }

    public publish(value: MapPositionObservation): boolean {
        const now = this.now();
        const { X, Y } = value.position;
        // Typed callers still need value validation; network input is parsed first.
        if (
            !Number.isSafeInteger(value.memberNumber) ||
            value.memberNumber <= 0 ||
            !Number.isInteger(X) ||
            X < 0 ||
            X >= 40 ||
            !Number.isInteger(Y) ||
            Y < 0 ||
            Y >= 40 ||
            !Number.isFinite(value.observedAt) ||
            value.observedAt > now ||
            now - value.observedAt >= 5_000 ||
            !positionSources.has(value.source)
        )
            return false;

        this.pruneExpired();
        const previous = this.cache.get(value.memberNumber);
        if (previous && previous.observedAt > value.observedAt) return false;
        if (!previous && this.cache.size >= this.maxEntries) return false;

        // Copy/freeze both layers: subscribers cannot rewrite another bot's evidence.
        const observation = Object.freeze({
            ...value,
            position: Object.freeze({ X, Y }),
        });
        this.cache.set(value.memberNumber, observation);
        return super.emit("positionObserved", observation);
    }

    public getLatestObservation(memberNumber: number) {
        const value = this.cache.get(memberNumber);
        if (!value) return undefined;
        const age = this.now() - value.observedAt;
        // Expire on reads too; an unref'ed maintenance timer is not an authority.
        if (age < 0 || age >= 5_000) {
            this.cache.delete(memberNumber);
            return undefined;
        }
        return value;
    }

    public subscribe(
        listener: (value: MapPositionObservation) => void | Promise<void>,
    ): () => void {
        const guarded = (value: MapPositionObservation) => {
            try {
                // Catch asynchronous rejection as well as synchronous exceptions.
                void Promise.resolve(listener(value)).catch(() => this.reportFailure());
            } catch {
                this.reportFailure();
            }
        };
        this.on("positionObserved", guarded);
        return () => this.off("positionObserved", guarded);
    }

    public forget(memberNumber: number): void {
        this.cache.delete(memberNumber);
    }

    public clear(): void {
        this.cache.clear();
    }

    public pruneExpired(): void {
        for (const member of this.cache.keys()) this.getLatestObservation(member);
    }
}

reportFailure must be a non-throwing, bounded structured logger owned by the composition root. Do not emit Node’s special unhandled "error" event. No raw .on()/.emit() consumers are permitted; bypassing the read-only port also bypasses the subscriber boundary. Keep Node’s listener warning, do not disable it. publish() returns whether the observation was accepted by the bounded cache, not a delivery acknowledgement; callers must not use it as success evidence.

The cache is scoped to one runtime/room/connection generation. Clear it before changing generation, and do not create a process-global singleton. For local observations inject a monotonic-derived epoch clock; remote imports additionally need the transport’s monotonic expiry guard described above.

The bus validation, expiration, capacity, copying, ordering, invalidation and subscriber isolation have unit coverage.

4. Implemented — publish from DoorFeaturePlugin

DoorFeaturePlugin receives the positionObservations publishing capability and injected clock through its options. Keep locationEvents unchanged. Publish raw observations before running asynchronous door logic; a door-rule failure must not hide the incoming BC position.

Integration fragments inside start():

const publishPosition = (memberNumber: number, position: BcMapPosition) => {
    const room = this.options.client.currentRoom;
    if (room?.Name !== configuredRoomName) return;
    if (!room.Character.some((member) => member.MemberNumber === memberNumber)) return;
    this.options.positionObservations.publish({
        memberNumber,
        position,
        observedAt: this.options.now(),
        source: "ChatRoomSyncMapData",
    });
};

const onMapPosition = (memberNumber: number, position: BcMapPosition) => {
    publishPosition(memberNumber, position); // Includes main, helper and other members.
    void this.runtime.observePosition(memberNumber, position).catch((error: unknown) => {
        console.error("Map position processing failed", {
            errorName: error instanceof Error ? error.name : "UnknownError",
        });
    });
};

const onRoomObserved = (
    room: BcRoomSnapshot,
    source: "ChatRoomSync" | "ChatRoomSyncRoomProperties",
) => {
    if (room.Name !== configuredRoomName) {
        this.options.positionObservations.clear();
        return;
    }
    if (source === "ChatRoomSync") {
        // Replace the roster projection; removed/missing positions must disappear.
        this.options.positionObservations.clear();
        const observedAt = this.options.now();
        for (const character of room.Character) {
            if (!character.MapData?.Pos) continue;
            this.options.positionObservations.publish({
                memberNumber: character.MemberNumber,
                position: character.MapData.Pos,
                observedAt,
                source,
            });
        }
    }
    // Properties-only sync retains an old roster: never re-date its coordinates.
    this.runtime.observeRoom(room, source);
};

const onCharacterLeft = (memberNumber: number) => {
    this.options.positionObservations.forget(memberNumber);
    this.runtime.forgetCharacter(memberNumber);
};
const onTransportState = () => this.options.positionObservations.clear();

const onAppearanceObserved = (observation: BcAppearanceObservation) => {
    const { source, character } = observation;
    // These three packets replace the whole character, including its map position.
    // Item/pose/expression patches carry retained coordinates, not fresh positions.
    if (
        source !== "ChatRoomSyncMemberJoin" &&
        source !== "ChatRoomSyncCharacter" &&
        source !== "ChatRoomSyncSingle"
    )
        return;
    const room = this.options.client.currentRoom;
    if (room?.Name !== configuredRoomName) return;
    if (!room.Character.some((member) => member.MemberNumber === character.MemberNumber)) return;
    if (!character.MapData?.Pos) {
        this.options.positionObservations.forget(character.MemberNumber);
        return;
    }
    // Project only coordinates; never forward appearance/private map state.
    this.options.positionObservations.publish({
        memberNumber: character.MemberNumber,
        position: character.MapData.Pos,
        observedAt: this.options.now(),
        source,
    });
};

configuredRoomName is a new injected option, not a global. Replace the existing handlers rather than registering duplicate ones. Register a disposer immediately after each client.on(...), using the plugin’s existing disposers array; subscribe onTransportState to transportState and onAppearanceObserved to the existing appearanceObserved event. Import BcAppearanceObservation as a type from the BC adapter’s appearance-protocol.ts; do not import it into core. The transport publisher must mirror clear/forget as invalidations, not only forward position events. Expose that invalidation subscription in its writer port during implementation.

ready remains responsible for runtime.seedCharacters. Do not publish the same snapshot again with a new timestamp on ready, configuration reload, or a timer. A full sync already publishes its positions. The current adapter has no characterObserved event: full-character updates are exposed through appearanceObserved, and joins additionally through characterEntered. Subscribing to both would duplicate a join observation. The source filter above also excludes ChatRoomSync because roomObserved handles the complete roster. Keep the five source literals aligned in the core type, publisher and stream schema. "Polling" is a delivery/query mechanism, not a fresh BC packet source.

Phase gate: full sync, per-member update, leave, reconnect and missing-position tests; unchanged door/location transitions; no publication from properties-only updates. Coverage of character-sync sources is required before production.

5. Implemented — use shared observations in MapTeleportAction

Inject PositionObservations through an optional options argument after the existing timeout argument, preserving current callers. Do not replace the existing queue or add a second journal. Extract the channel into a small map-owned module. The following factory is the baseline integration:

function createMapPositionChannel(
    client: BcClient,
    roomName: string,
    memberNumber: number,
    shared: PositionObservations,
): ObservationChannel<{ memberNumber: number; position: BcMapPosition }> {
    const inRoom = () => client.currentRoom?.Name === roomName;
    const latest = () => (inRoom() ? shared.getLatestObservation(memberNumber) : undefined);
    return {
        // Shared expiry must not revive an undated, possibly contradicted snapshot.
        // Only a fresh shared cache entry supports an initial no-op in this mode.
        current: latest,
        subscribe(listener) {
            const disposeShared = shared.subscribe((observation) => {
                if (!inRoom() || observation.memberNumber !== memberNumber) return;
                // The receiver validated epoch, age, room and observer identity.
                listener({ value: observation, source: `supervisor:${observation.source}` });
            });
            const onMapPosition = (member: number, position: BcMapPosition) => {
                // A fresh shared non-match must not be overridden by stale local state.
                if (!inRoom() || member !== memberNumber || latest()) return;
                listener({
                    value: { memberNumber, position },
                    source: "ChatRoomSyncMapData",
                });
            };
            const onRoomObserved = (
                room: BcRoomSnapshot,
                source: "ChatRoomSync" | "ChatRoomSyncRoomProperties",
            ) => {
                if (source !== "ChatRoomSync" || room.Name !== roomName || latest()) return;
                const character = room.Character.find(
                    (candidate) => candidate.MemberNumber === memberNumber,
                );
                if (character?.MapData?.Pos) {
                    listener({
                        value: { memberNumber, position: character.MapData.Pos },
                        source,
                    });
                }
            };
            client.on("mapPosition", onMapPosition);
            client.on("roomObserved", onRoomObserved);

            // Close the current()/subscribe() race without dispatching another packet.
            const cached = latest();
            if (cached) {
                listener({ value: cached, source: `supervisor:${cached.source}` });
            }
            return () => {
                disposeShared();
                client.off("mapPosition", onMapPosition);
                client.off("roomObserved", onRoomObserved);
            };
        },
    };
}

Required integration into the existing executeObserved request:

channel: createMapPositionChannel(this.client, this.roomName, memberNumber, observations),
matches: (value) =>
    value.memberNumber === memberNumber &&
    value.position.X === target.X &&
    value.position.Y === target.Y,
confirmationEvidence: {
    correlation: "member and matching BC-observed position; no BC request ID",
    observationPolicy: "fresh_supervisor_first_then_local",
},
dispatch: () => this.client.updateOwnMapPosition(target),

Keep the existing channel when the optional capability is absent. A cached matching value can justify already_satisfied before dispatch; a fresh matching event during the wait can justify observed. Neither proves the packet caused movement. Keep original source/time/observer epoch available in safe evidence; the coordinator’s observedAt means callback-receipt time, so do not present it as the supervisor’s capture time. The action uses dedicated evidence projectors to preserve safe source/capture metadata.

The example deliberately gives a fresh shared observation priority even over a conflicting local match. It may conservatively time out during conflicts. Do not guess cross-connection event ordering from delivery time. Qualification must verify this policy; conflicts should produce safe identifiers/counters, not raw packets. Shared mode never uses an undated local snapshot for current(), even after shared expiry/invalidation; otherwise old local coordinates could falsely become already_satisfied after a newer shared contradiction disappears. After shared expiry, only a new local event or a new shared observation can complete a pending action. Local callbacks are fenced on disconnect by the abort lifecycle. Do not repeatedly turn the old local snapshot into a new observation. When sharing is absent, the existing local-only channel is unchanged.

Polling fallback and resource cancellation

Default to event subscriptions plus the initial cache recheck above. If field qualification demonstrates a need, add a 100 ms, unref’ed, action-scoped poller that queries only validated cached observations and preserves their original capture time. It must not re-date snapshots, send BC read/write packets, or imply polling makes missing updates reliable. Clear the interval with all subscriptions on completion, timeout, dispatch failure, and shutdown.

ActionCoordinator resolves once and invokes the channel disposer on normal completion, timeout, dispatch failure, or abort. The optional abort signal is covered by coordinator tests. On abort, return a non-success outcome, remove all listeners/timers, and preserve uncertainty if dispatch occurred. Never fabricate an observed event to cancel. MapTeleportAction.stop() aborts active waits and rejects queued new work; transport disconnect also aborts the active wait.

6. Implemented — composition and HelperBotsPlugin lifecycle

HelperBotsPlugin must not construct DoorFeaturePlugin, other feature plugins, or shared singletons. Interpret “pass the bus to plugins” as dependency injection from start.ts, not plugin discovery or feature-to-feature imports.

Proposed transport-independent lifecycle port:

interface PositionObservationSession {
    start(): Promise<void>;
    stop(): Promise<void>;
}

The composition-root wiring is implemented in src/app/start.ts. It creates a process-local bus in either role, but constructs/starts the helper HTTP receiver or supervisor HTTP sender only when POSITION_OBSERVATION_ENABLED=true. MapTeleportAction receives the read-only bus only when enabled. The bus clock is monotonic-derived (performance.timeOrigin + performance.now()); production endpoint, host, port, and token requirements are parsed as one optional setting group in src/app/config.ts.

const monotonicEpochNow = () => performance.timeOrigin + performance.now();
const positionBus = new MapPositionObservationBus(monotonicEpochNow, () => {
    console.error("Position observation subscriber failed", { component: "position-bus" });
});
const mapTeleport = new MapTeleportAction(
    bot,
    actionCoordinator,
    mapRepository,
    config.room,
    5_000,
    config.positionObservationEnabled ? { observations: positionBus } : undefined,
);

// Statically imported, built-in HTTP adapter; disabled unless explicitly enabled.
const observationSession = config.positionObservationEnabled
    ? config.helperAgentId
        ? new HelperPositionObservationSession(helperOptions)
        : new SupervisorPositionObservationSession(supervisorOptions)
    : undefined;

const helperBotsPlugin = new HelperBotsPlugin({
    // ...all existing required options...
    observationSession,
    stopMovement: () => mapTeleport.stop(),
    teleportToStartLocation: (key) => mapTeleport.teleport(WORLD_ROOM_KEY, key),
});
// In the supervisor's existing DoorFeaturePlugin options:
// positionObservations: positionBus, now: Date.now, configuredRoomName: config.room

The actual adapter classes use only node:http. The helper sends its unique bearer token; the supervisor validates it and checks the registered helper’s member/room/runtime/fleet identity. The hello proof authenticates the supervisor response. The production endpoint must be a Railway .railway.internal URL; test runtime may use loopback. Constructing a local bus alone never opens a network listener.

Proposed changes inside HelperBotsPlugin:

// At the beginning of async start(), before registering worker-ready handling:
await this.options.observationSession?.start();
// If optional sharing is unavailable, its adapter reports degraded status and
// returns without a live cache; existing local observation and fencing remain.

// In async stop(), before the existing listener/timer cleanup:
this.stopped = true;
await this.options.stopMovement?.();
await this.options.observationSession?.stop();

Register partial-start disposers before awaiting resources, and make stop() idempotent even after startup failure. start() must not wait indefinitely for the main bot: bound handshake time below the startup budget. Connect the receiver before the helper can initiate startup teleport. Main publication is already installed by doors earlier in the supervisor plugin order.

The helper still verifies its registry identity, enabled state, fleet limits and startup location. Receiving the main bot’s evidence never bypasses those checks. Existing task execution must continue to verify its own allowed location and supervisor completion rules; fixing startup observation alone does not justify changing task execution authorization.

The shipped cache prunes expired entries on reads and writes and stays bounded even if no events arrive. Remote observation expiry also uses an action-scoped timer derived from the original capture time; it never re-dates a frame. The session clears its bus on stop or disconnect. Stop all plugins in reverse order, abort and drain the map action wait, close BC, persist stopped, flush debug logs, and close Mongo last as today.

The optional settings are POSITION_OBSERVATION_ENABLED, POSITION_OBSERVATION_LISTEN_HOST, POSITION_OBSERVATION_PORT, POSITION_OBSERVATION_HELPER_TOKENS, POSITION_OBSERVATION_ENDPOINT, POSITION_OBSERVATION_HELPER_TOKEN, and POSITION_OBSERVATION_HANDSHAKE_TIMEOUT_MS. They are documented alongside PrototypeConfig, .env.example, the root README, feature guides, architecture, and the Railway runbook. Sharing defaults off; no endpoint or token defaults target production. No BC packet or acknowledgement contract changed.

7. Tests, qualification and release gates

Use existing node:test/node:assert/strict, fakes and injected clocks. Register each new test file explicitly in package.json; do not add another framework.

Area Required cases
Local bus Fresh query; age 4,999 ms accepted/5,000 ms expired; future/NaN/infinite time; invalid member/coordinates/source; bounded capacity; immutable copies; older observation rejected; equal-time frames ordered by transport sequence; clear/forget/prune
Error boundaries Throwing and rejecting subscribers do not crash BC processing or prevent other listeners; sanitized bounded logger; dispose twice; no unhandled "error"
Main publisher Every supported inbound coordinate source, all members, initial full sync; no fresh timestamp from ready/properties/config reload; departure/missing coordinate/disconnect invalidations
Teleport Main-only matching observation succeeds while helper self echo is absent; shared cache yields already-satisfied; wrong member/room/runtime/epoch ignored; non-match not success; no evidence times out; conflict policy; subscription race; concurrent calls serialized
Cleanup Listener counts/timers return to baseline on success, timeout, failed dispatch/journal, disconnect, stop, partial start, reconnect and repeated stop; optional polling bounded/unref’ed
Transport Separate sessions communicate; token/HMAC and scope rejection; malformed/oversized frame; replay and sequence rejection; heartbeat liveness without evidence refresh; network loss and reconnect invalidation
Helpers Receiver starts before ready teleport; no task polling after timeout; optional capability absent retains local behavior; no identity/fleet/location authorization bypass
Infrastructure No new BC socket/emit owner; exact runtime/database mappings unchanged; no secrets/packet bodies in logs; no new persisted position authority

History-specific acceptance tests must cover the 10-visit limit, duplicate coordinate coalescing, departure persistence and memory release, last-known position surviving restart, expired-record filtering, TTL/index declarations, document-cap cleanup, failed writes/queue overflow, reconnect and stale-epoch write rejection. Prove that database history never produces already_satisfied or observed, never repopulates the live cache, and never crosses runtime/room scope. Use fake collections and injected clocks, not live MongoDB.

Use the existing map teleport, helper model/executor, action coordinator and fake socket tests as starting points. A movement dispatched but observed by neither connection must leave the worker fenced. Use injected clocks/timers where possible; loopback stream tests use synthetic credentials, never live BC/Mongo.

Run targeted tests after each phase, then all repository gates under Node 24:

npm run lint
npm run format:check
npm run check
npm test
npm run build

Build includes TypeDoc. Inspect generated API output locally; do not commit dist/. Run secret scanning and automated review/security validation before deployment.

Qualification before production

  1. Verify transport infrastructure/permissions and clock behavior in isolation.
  2. Use test runtime/database and dedicated non-production accounts. Capture only sanitized member/scope/source/age/status counters; do not save raw packets.
  3. Start the main observer and one helper with sharing off; record baseline.
  4. Enable publication/receiver in shadow mode: compare shared/local observations without changing action outcomes. Test disconnects, departures and stale data.
  5. Enable shared completion for that one helper, suppress its self echo in the fake integration harness, and verify one dispatch/one journal/one completion.
  6. Exercise timeout, supervisor restart, stream loss, room change and helper stop during a pending teleport. Confirm fencing and absence of automatic retries.
  7. Require operator sign-off on live self-echo behavior and latency before a limited production canary. Expand only after zero false-success observations, no uncaught failures, bounded memory/listeners and acceptable timeout rates.

8. Rollback and implementing-agent handoff

Immediate rollback in this change

Restore only the additions from fe054409d1a6cd5b5d36f2e5688b16624c322091; the existing location bus, earlier teleport room-sync fallback and unrelated helper behavior remain intact. npm run check is the regression gate for TS4114; a focused existing-suite test checks location entry/exit/disposal. This rollback does not claim to solve helper observation timeouts.

MVP rollback

Decisions resolved for the MVP and remaining production gates

Do not implement whispers, database polling, extra sockets, new dependencies, or a multi-bot single-process runtime as an unreviewed shortcut.

References