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.
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.
src/app/start.ts chooses only HelperBotsPlugin when HELPER_AGENT_ID is set;
otherwise it starts the supervisor feature list, including DoorFeaturePlugin.ChatRoomSyncMapData. The packet has no request ID or direct acknowledgement.
A full ChatRoomSync can also supply positions, but may not arrive in time.MapTeleportAction currently subscribes to its own client’s mapPosition
and roomObserved events. It cannot receive the main process’s events.MapLocationEventBus reports semantic location entry/exit, not every coordinate.
A movement within a region need not cause a location transition.BcClient snapshots.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.
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.
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:
roomKey, memberNumber, observer epoch and monotonic revision within that
epoch, for scoped lookup and stale-write rejection;visits (maximum 10), lastKnownPosition, lastObservedAt, and
coverageEndedAt/coverageEndReason;updatedAt as epoch milliseconds and expiresAt as a BSON Date.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.
| 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.
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:
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.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.
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.
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.
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.
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.
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.
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.
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.
MapTeleportAction.stop() and BC disconnect abort waits.
Before dispatch the action is not emitted; after dispatch the journal remains
timed_out_unobserved unless matching evidence already arrived.Do not implement whispers, database polling, extra sockets, new dependencies, or a multi-bot single-process runtime as an unreviewed shortcut.