Skip to content

feat: [ENG-2930] brv channel transport events + validating handler skeleton#747

Merged
leehpham merged 1 commit into
proj/multi-agent-collabfrom
feat/ENG-2930
May 30, 2026
Merged

feat: [ENG-2930] brv channel transport events + validating handler skeleton#747
leehpham merged 1 commit into
proj/multi-agent-collabfrom
feat/ENG-2930

Conversation

@leehpham
Copy link
Copy Markdown
Member

M0-3 — Transport events + handler skeleton

Lays down the brv channel wire contract and a validating daemon handler skeleton. No agent behavior yet — every handler validates its payload, then returns a typed not-implemented, so M1–M4 fill in behavior without touching the wire shape or command layer.

Linear: ENG-2930 · builds on M0-1 (ENG-2928) + M0-2 (ENG-2929).

What's included

  • Wire contractsrc/shared/transport/events/channel-events.ts: ChannelEvents (10 request events + 3 broadcasts) with <Op>RequestSchema/<Op>ResponseSchema Zod pairs reusing the M0-1 domain schemas (mention/show carry Turn/TurnEvent unchanged).
  • Typed errorssrc/server/core/domain/channel/errors.ts: CHANNEL_NOT_IMPLEMENTED / CHANNEL_DISABLED / CHANNEL_INVALID_REQUEST (thrown ChannelError subclasses carrying .code, which the Socket.IO transport serializes into the {success:false, code} envelope).
  • Per-event handlers — 10 classes under src/server/infra/transport/handlers/channel/, each implementing the new general ITransportHandler<Request, Response> (handle + setup). setup() wires onRequest → handle; handle() runs parseOrThrow then throws — the M1 seam for the orchestrator call.
  • Feature gatechannel-disabled-handler.ts: channelsEnabled() (opt-out, BRV_CHANNELS_ENABLED) + registerDisabledStubs(). Registered inline in feature-handlers.ts behind the gate.

Acceptance criteria

  • malformed payload → CHANNEL_INVALID_REQUEST; valid → CHANNEL_NOT_IMPLEMENTED (flag on); flag off → CHANNEL_DISABLED. ✅
  • 36 channel unit tests passing; npm run typecheck + npm run lint clean. ✅

Deviations from the issue spec (deliberate)

  • Per-event handler classes + ITransportHandler instead of one ChannelHandler with registerOn (maintainability; shared contract for future handler refactors).
  • withAuth / projectRootFromCtx(ctx) deferred to M1 — the repo's onRequest is (data, clientId) (no ctx), and not-implemented bodies need neither.
  • Gate wired in feature-handlers.ts (the registration hub), not brv-server.ts.
  • Tests under test/unit/infra/transport/handlers/channel/ (repo convention drops the server/ segment).

Out of scope (later milestones)

IChannelOrchestrator wiring + real handle bodies (M1), channel:permission-decision (M4), oclif command stubs + brv channel --help (ENG-2933 / M0-4).

@leehpham leehpham requested a review from bao-byterover as a code owner May 30, 2026 16:28
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

Claude finished @leehpham's task in 4m 18s —— View job


Claude Code Review

Overall score: 4 / 5 — Solid skeleton. Wire contract is well-scoped, errors are correctly typed for the transport envelope, the feature gate is right (opt-out + stubs to avoid client hangs), and 36 unit tests are present. A handful of concrete issues to address before merge — none blocking by themselves, but worth fixing while the surface has no consumers yet.

Findings (inline comments posted)

# Severity File Note
1 issue (style) channel/channel-create-handler.ts Diverges from the other 9 handlers: double quotes, semicolons, inlined import, missing JSDoc, request => vs (request) =>. Lint may pass but the drift is jarring. Suggestion block posted.
2 issue (maintainability) channel-disabled-handler.ts:7-18 STUBBABLE_EVENTS duplicates the per-event handler list in feature-handlers.ts:153-162. A new channel:* request (e.g. M4's permission-decision) needs three places updated; drift means a request silently hangs when the surface is disabled.
3 issue (security) channel-events.ts:147-151 ChannelListTurnsRequestSchema.limit has no upper bound — a client can request Number.MAX_SAFE_INTEGER once M1 wires the orchestrator. Clamp at the wire schema (.max(200) or similar) so M1 implementers don't have to defend against it.
4 issue (correctness) channel-events.ts:80-89, 103 invocation: z.unknown().optional() accepts arrays/primitives/null. When M1 tightens to a LaunchSpec, that's a wire-breaking change for any client sending a non-object. Narrow to z.record(z.unknown()) now to lock in "object-shaped" without committing to the eventual key set.
5 question events/index.ts:8 ChannelEvents is exported via export * but not added to AllEventGroups. Every other …Events const is in there. Intentional (channels excluded from broad subscriptions) or oversight?
6 question handlers/i-transport-handler.ts New interface used only by channel handlers — meant to become the canonical contract that the rest of handlers/ migrates to, or channel-specific? Also: setup() drops clientId; the M1 auth seam will need it. Worth a one-line JSDoc flag.
7 nitpick channel-disabled-handler.ts:40 BRV_CHANNELS_ENABLED should land in CLAUDE.md's Environment section so it's discoverable.
8 praise channel/parse-or-throw.ts Single-responsibility utility, returns the parsed T (right shape for M1), throws the typed error with flattened zod details.

What's good

  • Error class hierarchy (errors.ts) — ChannelError carries code + details; subclasses set name correctly and one extends with structured details for invalid-request validation failures. Clean serialization story for the {success:false, code} envelope.
  • Feature gate is correctchannelsEnabled() is opt-out (default-on but disablable via env), and the disabled path registers stubs rather than silently dropping the events, so clients still get an ack with CHANNEL_DISABLED instead of hanging — this is the right call.
  • Tests cover the three acceptance branches per handler (registers on setup / valid → NOT_IMPLEMENTED / invalid → INVALID_REQUEST) and the disabled-stubs path validates both the request-event surface and that broadcasts are not stubbed.
  • Schema reuse from M0-1mention/show carry Turn/TurnEvent unchanged; no parallel definitions.

Acceptance criteria

  • Malformed payload → CHANNEL_INVALID_REQUEST: ✅
  • Valid payload (flag on) → CHANNEL_NOT_IMPLEMENTED: ✅
  • Flag off → CHANNEL_DISABLED: ✅
  • 36 channel unit tests present: ✅ (verified by reading each test file)

Suggested next step

Address items #1-4 (the issues), then file follow-ups for #5-6 (the questions) so the M1 author has a clean seam to build on. I have not verified npm run typecheck / npm run lint / the test suite locally — recommend re-running on CI after the suggested fixes.


  • Gather context (PR body + CLAUDE.md)
  • Read changed files (wire contract, errors, handlers, tests)
  • Verify acceptance criteria match implementation
  • Check correctness, security, and code quality
  • Post inline comments for specific issues (7 posted)
  • Post summary with score
    · feat/ENG-2930

@leehpham leehpham merged commit 45d81c0 into proj/multi-agent-collab May 30, 2026
6 checks passed
Comment on lines +1 to +25
import type {ITransportServer} from "../../../../core/interfaces/transport/index.js";
import type {ITransportHandler} from "../i-transport-handler.js";

import {type ChannelCreateRequest, ChannelCreateRequestSchema, type ChannelCreateResponse, ChannelEvents} from "../../../../../shared/transport/events/channel-events.js";
import {ChannelNotImplementedError} from "../../../../core/domain/channel/errors.js";
import {parseOrThrow} from "./parse-or-throw.js";

export class ChannelCreateHandler implements ITransportHandler<ChannelCreateRequest, ChannelCreateResponse> {
private readonly event: string
private readonly transportServer: ITransportServer

public constructor(transportServer: ITransportServer) {
this.event = ChannelEvents.CREATE
this.transportServer = transportServer
}

public async handle(request: ChannelCreateRequest): Promise<ChannelCreateResponse> {
parseOrThrow(ChannelCreateRequestSchema, request)
throw new ChannelNotImplementedError(this.event)
}

public setup(): void {
this.transportServer.onRequest<ChannelCreateRequest, ChannelCreateResponse>(this.event, request => this.handle(request))
}
} No newline at end of file
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (style): This file is the odd one out — all 9 sibling handlers use single quotes, no semicolons, multi-line imports, a class-level JSDoc, and (request) => … arrow syntax. This handler uses double quotes, semicolons, an inlined import, no JSDoc, and request => …. The PR body claims npm run lint is clean (so perhaps Prettier isn't enforced cross-file), but the visual drift across an otherwise uniform skeleton is jarring and will keep showing up in future diffs to this directory.

Suggested change
import type {ITransportServer} from "../../../../core/interfaces/transport/index.js";
import type {ITransportHandler} from "../i-transport-handler.js";
import {type ChannelCreateRequest, ChannelCreateRequestSchema, type ChannelCreateResponse, ChannelEvents} from "../../../../../shared/transport/events/channel-events.js";
import {ChannelNotImplementedError} from "../../../../core/domain/channel/errors.js";
import {parseOrThrow} from "./parse-or-throw.js";
export class ChannelCreateHandler implements ITransportHandler<ChannelCreateRequest, ChannelCreateResponse> {
private readonly event: string
private readonly transportServer: ITransportServer
public constructor(transportServer: ITransportServer) {
this.event = ChannelEvents.CREATE
this.transportServer = transportServer
}
public async handle(request: ChannelCreateRequest): Promise<ChannelCreateResponse> {
parseOrThrow(ChannelCreateRequestSchema, request)
throw new ChannelNotImplementedError(this.event)
}
public setup(): void {
this.transportServer.onRequest<ChannelCreateRequest, ChannelCreateResponse>(this.event, request => this.handle(request))
}
}
import type {ITransportServer} from '../../../../core/interfaces/transport/index.js'
import type {ITransportHandler} from '../i-transport-handler.js'
import {
ChannelCreateRequest,
ChannelCreateRequestSchema,
ChannelCreateResponse,
ChannelEvents,
} from '../../../../../shared/transport/events/channel-events.js'
import {ChannelNotImplementedError} from '../../../../core/domain/channel/errors.js'
import {parseOrThrow} from './parse-or-throw.js'
/**
* Handles `channel:create`. `handle` validates the payload, then throws
* {@link ChannelNotImplementedError}; the create behavior lands in a later
* milestone, written after the validation step in `handle`.
*/
export class ChannelCreateHandler
implements ITransportHandler<ChannelCreateRequest, ChannelCreateResponse>
{
private readonly event: string
private readonly transportServer: ITransportServer
public constructor(transportServer: ITransportServer) {
this.event = ChannelEvents.CREATE
this.transportServer = transportServer
}
public async handle(request: ChannelCreateRequest): Promise<ChannelCreateResponse> {
parseOrThrow(ChannelCreateRequestSchema, request)
throw new ChannelNotImplementedError(this.event)
}
public setup(): void {
this.transportServer.onRequest<ChannelCreateRequest, ChannelCreateResponse>(this.event, (request) =>
this.handle(request),
)
}
}

Comment on lines +7 to +18
const STUBBABLE_EVENTS = [
ChannelEvents.CREATE,
ChannelEvents.LIST,
ChannelEvents.GET,
ChannelEvents.INVITE,
ChannelEvents.ONBOARD,
ChannelEvents.MENTION,
ChannelEvents.SHOW,
ChannelEvents.LIST_TURNS,
ChannelEvents.SUBSCRIBE,
ChannelEvents.CANCEL,
] as const
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (maintainability): STUBBABLE_EVENTS re-lists the same 10 events that feature-handlers.ts instantiates handlers for (lines 153-162). If a new channel:* request event is added later (e.g. M4's channel:permission-decision), three places need to stay in sync: the per-event handler instantiation in feature-handlers.ts, this list, and ChannelEvents itself. A drift here means a request event registered in the "enabled" branch silently hangs in the "disabled" branch with no ack.

One option: drive both from a single source — e.g. a CHANNEL_REQUEST_EVENTS tuple exported from channel-events.ts, used by both feature-handlers.ts (as keys for the handler-instance map) and registerDisabledStubs. Out of scope for the skeleton, but worth a TODO if you don't want to do it now.

* Reports whether the channel surface is enabled. Opt-out: enabled unless
* `BRV_CHANNELS_ENABLED` is `0`, `false`, `no`, or `off` (case-insensitive).
*/
export const channelsEnabled = (env: NodeJS.ProcessEnv = process.env): boolean => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (code quality): Default-arg evaluation is fine here, but the gate is only consulted once at boot in feature-handlers.ts — using process.env directly inside is a tiny readability win (the parameter signals "this is testable" — keep it). The bigger note: BRV_CHANNELS_ENABLED should land in CLAUDE.md's Environment section so future contributors can find it; right now the only documentation of this env var is this file's docstring.

export * from './agent-events.js'
export * from './auth-events.js'
export * from './billing-events.js'
export * from './channel-events.js'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: ChannelEvents is exported via export * here but is NOT added to AllEventGroups (lines 69-98). AllEventGroups is currently unused inside the repo, so there's no immediate functional gap — but the convention every other events module follows is "if it's a …Events const, it goes in AllEventGroups". Was the omission intentional (e.g. channels stay out of broad subscriptions/diagnostics), or just an oversight?

If intentional, a one-line comment in the array explaining why channels is excluded would help future readers. If oversight:

Suggested change
export * from './channel-events.js'
import {ChannelEvents} from './channel-events.js'

(plus an entry in the AllEventGroups tuple.)

Comment on lines +147 to +151
export const ChannelListTurnsRequestSchema = z.object({
channelId: z.string(),
cursor: z.string().optional(),
limit: z.number().int().positive().optional(),
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (security): limit is z.number().int().positive().optional() with no upper bound. Once the orchestrator lands and this actually queries a turn store, a malicious or buggy client can request limit: Number.MAX_SAFE_INTEGER and force an unbounded scan/serialization. The wire contract is the cheapest place to clamp this — e.g. .max(200) (pick a sensible cap). Catching it at the schema means M1 implementers don't have to remember to defend against it.

Suggested change
export const ChannelListTurnsRequestSchema = z.object({
channelId: z.string(),
cursor: z.string().optional(),
limit: z.number().int().positive().optional(),
})
export const ChannelListTurnsRequestSchema = z.object({
channelId: z.string(),
cursor: z.string().optional(),
limit: z.number().int().positive().max(200).optional(),
})

Comment on lines +80 to +89
/**
* channel:invite — `invocation` is intentionally loose at the skeleton layer;
* the launch-spec shape is tightened when the driver/profile machinery lands.
*/
export const ChannelInviteRequestSchema = z.object({
channelId: z.string(),
handle: HandleSchema,
invocation: z.unknown().optional(),
profileName: z.string().optional(),
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (correctness): invocation: z.unknown().optional() is documented as "intentionally loose at the skeleton layer", which is reasonable — but z.unknown() accepts any JSON value including arrays, primitives, and null. When M1 tightens this to a LaunchSpec shape, that's a wire-breaking change to clients that have been sending invocation: "hello" or invocation: [1, 2, 3] and getting valid acks.

If you want to keep this open without painting yourself into a wire-break, z.record(z.unknown()).optional() (or even z.object({}).passthrough().optional()) at least asserts "object-shaped" now, so the future tightening only narrows record keys instead of also rejecting whole new value kinds. Same note applies to ChannelOnboardRequestSchema.invocation (line 103).

}

return parsed.data
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Nice small utility — single responsibility, throws the right typed error with flattened zod details, and the per-handler call sites stay one line. Forwarding the parsed T (rather than just returning void) is also the right shape for M1, when handlers actually need the narrowed value.

export interface ITransportHandler<Request, Response> {
handle(request: Request): Promise<Response>
setup(): void
} No newline at end of file
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Is this intended to become the canonical handler contract that other (non-channel) handlers in index.ts migrate to as well? Right now it's a single 4-line file with no JSDoc, used only by the 10 channel handlers. If it's general, a brief docstring on the interface explaining the handle vs setup split (and the setup → onRequest → handle lifecycle) would help. If it's channel-specific, the file is fine where it is but should probably move under handlers/channel/ to avoid implying broader scope.

Also: handle discards clientId because setup() calls onRequest(event, (request) => this.handle(request)). That's correct for the not-implemented skeleton, but the PR body notes "withAuth / projectRootFromCtx(ctx) deferred to M1" — when those land, the handler will need clientId, which means either widening handle to (request, clientId) or wrapping at setup(). Worth a one-liner here flagging that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant