Initial release: MCP server enforcing Worker-Reviewer loop

Diligence prevents AI agents from shipping quick fixes that break things
by enforcing a research-propose-verify loop before any code changes.

Key features:
- Worker sub-agent researches and proposes with file:line citations
- Reviewer sub-agent independently verifies claims by searching codebase
- Iterates until approved (max 5 rounds)
- Loads project-specific context from .claude/CODEBASE_CONTEXT.md
- State persisted across sessions

Validated on production codebase: caught architectural mistake (broker
subscriptions on client-side code) that naive agent would have shipped.
This commit is contained in:
2026-01-22 06:22:59 +01:00
commit bd178fcaf0
23 changed files with 4001 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
# Codebase Context: Test Fixture
This is a simplified test codebase that mirrors real-world patterns. Use this context to understand the architecture before making changes.
## Architecture Overview
```
src/
├── broker/
│ └── events.ts # Broker event bus (Subject-based pub/sub)
├── services/
│ ├── user-block.service.ts # Blocking logic
│ ├── voice-channel.service.ts # Voice channels and DM calls
│ ├── chat.service.ts # Chat channels and messages
│ └── team.service.ts # Team state and permission caching
└── controllers/
└── roles.controller.ts # REST API for roles
```
## Critical Pattern: Broker Events
**All state changes that affect multiple services MUST emit broker events.**
### Available Events
| Event | Emitted When | Expected Subscribers |
|-------|--------------|---------------------|
| `BusUserBlockChange` | User blocks/unblocks another | Voice services, DM services |
| `BusTeamRoleChange` | Role created/updated/deleted | Permission caches |
| `BusTeamMemberRoleChange` | User role assigned/removed | Permission caches |
| `BusVoiceParticipant` | User joins/leaves voice | Voice UI components |
| `BusDmCall` | DM call state changes | Call observers |
### Pattern Example
```typescript
// CORRECT: Emit event after state change
async updateRole(teamId, roleId, updates) {
const role = await db.update(roleId, updates);
BusTeamRoleChange.next({ // ← MUST emit event
teamId,
roleId,
action: 'updated',
timestamp: new Date(),
});
return role;
}
```
## Critical Pattern: Permission vs Action Checks
**Permission = Visibility. Action = Separate Check.**
### Why This Matters
For DM channels, blocking creates a `'read'` permission, NOT `'denied'`. The user can still SEE the DM channel, but cannot SEND messages.
```typescript
// Permission check (for visibility)
if (isBlocked) {
return { permission: 'read', reason: 'blocked' }; // ← 'read', not 'denied'
}
// Action check (separate from permission)
async sendMessage(userId, channel, content) {
if (await isBlockingEitherWay(userA, userB)) {
throw new Error('Cannot send messages'); // ← Separate check
}
}
```
### Voice Permission Pattern
For DM channels, voice permissions are **always true**:
```typescript
return {
permission: 'read',
voiceListen: true, // Always true for DM
voiceTalk: true, // Blocking checked on JOIN, not here
voiceWebcam: true,
voiceScreenshare: true,
};
```
Blocking is enforced by **action checks** in:
- `joinVoiceChannel()` - line 33
- `startDmCall()` - line 56
## Critical Pattern: Cache Invalidation
**Caches MUST subscribe to relevant broker events.**
### Current Bug Pattern
```typescript
// BAD: Only clears on team switch
constructor() {
teamChange$.subscribe(() => {
this.memoizedPermissions.clear();
});
}
// GOOD: Also clear on role changes
constructor() {
teamChange$.subscribe(() => this.clearCache());
BusTeamRoleChange.subscribe(() => this.clearCache()); // ← ADD THIS
BusTeamMemberRoleChange.subscribe(() => this.clearCache()); // ← AND THIS
}
```
## Checklist: Before Making Changes
### For ANY state change:
1. [ ] Does this change affect other services?
2. [ ] Is there a broker event for this? If not, should there be?
3. [ ] Are all relevant services subscribed to the event?
### For blocking-related changes:
1. [ ] Is blocking checked on all relevant ACTIONS (not just permissions)?
2. [ ] What happens if block is created DURING an action (e.g., mid-call)?
3. [ ] Are broker events emitted for blocking changes?
4. [ ] Do voice services subscribe to `BusUserBlockChange`?
### For permission/cache changes:
1. [ ] What events should invalidate this cache?
2. [ ] Is the cache subscribed to all relevant broker events?
3. [ ] What's the TTL? Is stale data acceptable?
## Files Quick Reference
| File | Key Functions | Known Issues |
|------|---------------|--------------|
| `user-block.service.ts` | `blockUser()`, `unblockUser()` | Missing voice cleanup on block |
| `voice-channel.service.ts` | `answerDmCall()`, `startDmCall()` | Missing blocking check on answer |
| `team.service.ts` | `getPermission()`, `clearCache()` | Cache doesn't subscribe to role events |
| `roles.controller.ts` | `createRole()`, `deleteRole()` | Missing broker events |
| `chat.service.ts` | `getChannelPermission()` | **Reference implementation** (correct) |
## Anti-Patterns to Avoid
1. **Fixing in ONE place** - If blocking is checked in `startDmCall()`, it should also be in `answerDmCall()`
2. **Changing permissions** - Don't change `voiceListen: true` to `voiceListen: !isBlocked`. Use action checks instead.
3. **Forgetting broker events** - Every CRUD operation on roles/permissions should emit an event
4. **Assuming cache is fresh** - If an operation can change state, subscribe to its event

View File

@@ -0,0 +1,79 @@
/**
* Broker Event Bus System
*
* All state changes that affect multiple services should emit broker events.
* Services subscribe to these events to maintain consistency.
*/
import { Subject } from 'rxjs';
// ============================================================================
// User Events
// ============================================================================
export interface UserBlockEvent {
sourceUserId: string;
targetUserId: string;
blocked: boolean; // true = blocked, false = unblocked
timestamp: Date;
}
export const BusUserBlockChange = new Subject<UserBlockEvent>();
// ============================================================================
// Team Events
// ============================================================================
export interface TeamRoleEvent {
teamId: string;
roleId: string;
action: 'created' | 'updated' | 'deleted';
flags?: number;
timestamp: Date;
}
export interface TeamMemberRoleEvent {
teamId: string;
userId: string;
roleId: string;
action: 'assigned' | 'removed';
timestamp: Date;
}
export const BusTeamRoleChange = new Subject<TeamRoleEvent>();
export const BusTeamMemberRoleChange = new Subject<TeamMemberRoleEvent>();
// ============================================================================
// Voice Events
// ============================================================================
export interface VoiceParticipantEvent {
channelId: string;
userId: string;
action: 'joined' | 'left' | 'kicked';
timestamp: Date;
}
export interface DmCallEvent {
callId: string;
callerId: string;
calleeId: string;
state: 'ringing' | 'active' | 'ended' | 'declined';
timestamp: Date;
}
export const BusVoiceParticipant = new Subject<VoiceParticipantEvent>();
export const BusDmCall = new Subject<DmCallEvent>();
// ============================================================================
// Channel Events
// ============================================================================
export interface ChannelMemberEvent {
channelId: string;
userId: string;
hidden: boolean;
timestamp: Date;
}
export const BusChannelMember = new Subject<ChannelMemberEvent>();

View File

@@ -0,0 +1,99 @@
/**
* Roles Controller
*
* REST API for managing team roles.
*/
import { BusTeamRoleChange } from '../broker/events';
interface TeamRole {
id: string;
teamId: string;
name: string;
flags: number;
}
// In-memory store
const roles: TeamRole[] = [];
export class RolesController {
/**
* Create a new role.
*
* BUG: Doesn't emit BusTeamRoleChange event!
* Clients won't know a new role was created.
*/
async createRole(teamId: string, name: string, flags: number): Promise<TeamRole> {
const role: TeamRole = {
id: `role_${Date.now()}`,
teamId,
name,
flags,
};
roles.push(role);
// BUG: Missing broker event!
// Should emit:
// BusTeamRoleChange.next({
// teamId,
// roleId: role.id,
// action: 'created',
// flags,
// timestamp: new Date(),
// });
return role;
}
/**
* Update an existing role.
*
* Emits broker event correctly (this one is fine).
*/
async updateRole(teamId: string, roleId: string, updates: Partial<TeamRole>): Promise<TeamRole> {
const role = roles.find(r => r.id === roleId && r.teamId === teamId);
if (!role) throw new Error('Role not found');
Object.assign(role, updates);
// This one correctly emits the event
BusTeamRoleChange.next({
teamId,
roleId: role.id,
action: 'updated',
flags: role.flags,
timestamp: new Date(),
});
return role;
}
/**
* Delete a role.
*
* BUG: Doesn't emit BusTeamRoleChange event!
* Clients won't know the role was deleted, will have stale data.
*/
async deleteRole(teamId: string, roleId: string): Promise<void> {
const index = roles.findIndex(r => r.id === roleId && r.teamId === teamId);
if (index === -1) throw new Error('Role not found');
roles.splice(index, 1);
// BUG: Missing broker event!
// Should emit:
// BusTeamRoleChange.next({
// teamId,
// roleId,
// action: 'deleted',
// timestamp: new Date(),
// });
}
/**
* Get all roles for a team.
*/
async getRoles(teamId: string): Promise<TeamRole[]> {
return roles.filter(r => r.teamId === teamId);
}
}

View File

@@ -0,0 +1,122 @@
/**
* Chat Service
*
* Manages chat channels and permissions.
* This shows the CORRECT pattern for handling blocking in chat.
*/
import { UserBlockService } from './user-block.service';
interface ChannelPermission {
permission: 'read' | 'write' | 'admin' | 'denied';
voiceListen: boolean;
voiceTalk: boolean;
voiceWebcam: boolean;
voiceScreenshare: boolean;
reason?: string;
}
interface Channel {
id: string;
type: 'dm' | 'project';
userA?: string; // For DM channels
userB?: string; // For DM channels
projectId?: string; // For project channels
}
export class ChatService {
constructor(private userBlockService: UserBlockService) {}
/**
* Get permission for a channel.
*
* For DM channels:
* - Blocking returns 'read' permission (can see messages, can't send)
* - This is the CORRECT pattern: permission = visibility, not action validation
*
* For voice permissions in DM:
* - voiceListen, voiceTalk, etc. are always TRUE for DM channels
* - This is INTENTIONAL: voice blocking is handled by action checks, not permissions
*
* Pattern: Permission controls VISIBILITY; Actions have SEPARATE blocking checks.
* See chat.sendMessage() for how blocking is enforced on actions.
*/
async getChannelPermission(
userId: string,
channel: Channel
): Promise<ChannelPermission> {
if (channel.type === 'dm' && channel.userA && channel.userB) {
const otherUser = channel.userA === userId ? channel.userB : channel.userA;
// Check blocking status
const isBlockingOut = await this.userBlockService.isBlocking(userId, otherUser);
const isBlockingInc = await this.userBlockService.isBlocking(otherUser, userId);
// Return 'read' permission for blocked DMs (can see, can't send)
// This is the correct pattern - permission controls visibility
if (isBlockingOut) {
return {
permission: 'read',
reason: 'block-user',
// Voice permissions are always true for DM - blocking is checked on actions
voiceListen: true,
voiceTalk: true,
voiceWebcam: true,
voiceScreenshare: true,
};
}
if (isBlockingInc) {
return {
permission: 'read',
reason: 'blocked-by-user',
voiceListen: true,
voiceTalk: true,
voiceWebcam: true,
voiceScreenshare: true,
};
}
// Normal DM permission
return {
permission: 'write',
voiceListen: true,
voiceTalk: true,
voiceWebcam: true,
voiceScreenshare: true,
};
}
// Project channel - normal permission flow
return {
permission: 'write',
voiceListen: true,
voiceTalk: true,
voiceWebcam: true,
voiceScreenshare: true,
};
}
/**
* Send a message to a channel.
*
* This is the CORRECT pattern for blocking enforcement:
* - Check blocking SEPARATELY from permission
* - Permission controls visibility; this check controls action
*/
async sendMessage(userId: string, channel: Channel, content: string): Promise<void> {
// Separate blocking check for the ACTION (not permission)
if (channel.type === 'dm' && channel.userA && channel.userB) {
const isBlocked = await this.userBlockService.isBlockingEitherWay(
channel.userA,
channel.userB
);
if (isBlocked) {
throw new Error('You cannot send messages to this user');
}
}
// Send the message...
console.log(`[chat] ${userId} -> ${channel.id}: ${content}`);
}
}

View File

@@ -0,0 +1,102 @@
/**
* Team Service (Client-side)
*
* Manages team state including permissions and role caching.
*/
import { BusTeamRoleChange, BusTeamMemberRoleChange } from '../broker/events';
interface Permission {
permission: 'read' | 'write' | 'admin' | 'denied';
voiceListen: boolean;
voiceTalk: boolean;
voiceWebcam: boolean;
voiceScreenshare: boolean;
}
interface Team {
id: string;
name: string;
}
/**
* Permission cache for computed permissions.
*
* BUG: This cache only clears on team switch, not on role changes!
* When a user's roles change, their cached permissions become stale.
*/
const memoizedPermissions = new Map<string, Permission>();
export class TeamService {
private currentTeam: Team | null = null;
constructor() {
// Subscribe to team changes to clear cache
// BUG: Only clears on team SWITCH, not on role updates!
this.setupTeamChangeSubscription();
// BUG: Missing subscription to role changes!
// Should subscribe to BusTeamRoleChange and BusTeamMemberRoleChange
// and clear the cache when roles change.
}
/**
* Get cached permission for a project.
*/
getPermission(projectId: string): Permission | undefined {
return memoizedPermissions.get(projectId);
}
/**
* Set cached permission for a project.
*/
setPermission(projectId: string, permission: Permission): void {
memoizedPermissions.set(projectId, permission);
}
/**
* Clear permission cache.
*
* Called when active team changes.
* BUG: Should also be called when roles change!
*/
clearPermissionCache(): void {
memoizedPermissions.clear();
}
/**
* Switch to a different team.
*/
setActiveTeam(team: Team): void {
this.currentTeam = team;
this.clearPermissionCache();
}
/**
* Setup subscription to team changes.
*
* BUG: Only subscribes to team SWITCH, not to:
* - BusTeamRoleChange (role created/updated/deleted)
* - BusTeamMemberRoleChange (user role assigned/removed)
*/
private setupTeamChangeSubscription(): void {
// This would normally be an observable subscription
// For now, we just clear on setActiveTeam()
}
/**
* FIX: Should add these subscriptions:
*
* BusTeamRoleChange.subscribe(event => {
* if (event.teamId === this.currentTeam?.id) {
* this.clearPermissionCache();
* }
* });
*
* BusTeamMemberRoleChange.subscribe(event => {
* if (event.teamId === this.currentTeam?.id) {
* this.clearPermissionCache();
* }
* });
*/
}

View File

@@ -0,0 +1,117 @@
/**
* User Block Service
*
* Handles blocking/unblocking between users.
* Blocking affects:
* - DM visibility
* - Voice call permissions
* - Feed following
*/
import { BusUserBlockChange } from '../broker/events';
interface UserBlockRecord {
sourceUserId: string;
targetUserId: string;
createdAt: Date;
}
// In-memory store for testing
const blocks: UserBlockRecord[] = [];
export class UserBlockService {
/**
* Block a user.
*
* When a user is blocked:
* 1. They can no longer send DMs
* 2. They are unfollowed from feeds
* 3. DM channel becomes read-only
*
* BUG: Missing voice call cleanup!
* - Should end any active DM call between these users
* - Should kick from shared voice channels
*/
async blockUser(sourceUserId: string, targetUserId: string): Promise<void> {
// Check if already blocked
const existing = blocks.find(
b => b.sourceUserId === sourceUserId && b.targetUserId === targetUserId
);
if (existing) return;
// Create block record
const block: UserBlockRecord = {
sourceUserId,
targetUserId,
createdAt: new Date(),
};
blocks.push(block);
// Unfollow in both directions
await this.unfollowUser(sourceUserId, targetUserId);
await this.unfollowUser(targetUserId, sourceUserId);
// Emit broker event
BusUserBlockChange.next({
sourceUserId,
targetUserId,
blocked: true,
timestamp: block.createdAt,
});
// BUG: No voice cleanup here!
// Should call: voiceChannelService.endDmCallBetweenUsers(sourceUserId, targetUserId)
// Should call: voiceChannelService.kickFromSharedChannels(sourceUserId, targetUserId)
}
/**
* Unblock a user.
*/
async unblockUser(sourceUserId: string, targetUserId: string): Promise<void> {
const index = blocks.findIndex(
b => b.sourceUserId === sourceUserId && b.targetUserId === targetUserId
);
if (index === -1) return;
blocks.splice(index, 1);
// Unhide DM channel
await this.unhideDmChannel(sourceUserId, targetUserId);
// Emit broker event
BusUserBlockChange.next({
sourceUserId,
targetUserId,
blocked: false,
timestamp: new Date(),
});
}
/**
* Check if either user has blocked the other.
*/
async isBlockingEitherWay(userA: string, userB: string): Promise<boolean> {
return blocks.some(
b =>
(b.sourceUserId === userA && b.targetUserId === userB) ||
(b.sourceUserId === userB && b.targetUserId === userA)
);
}
/**
* Check if source has blocked target.
*/
async isBlocking(sourceUserId: string, targetUserId: string): Promise<boolean> {
return blocks.some(
b => b.sourceUserId === sourceUserId && b.targetUserId === targetUserId
);
}
private async unfollowUser(userId: string, targetId: string): Promise<void> {
// Unfollow logic...
}
private async unhideDmChannel(userA: string, userB: string): Promise<void> {
// Unhide DM channel logic...
}
}

View File

@@ -0,0 +1,220 @@
/**
* Voice Channel Service
*
* Manages voice channel state, participants, and DM calls.
*/
import { BusVoiceParticipant, BusDmCall, BusUserBlockChange } from '../broker/events';
import { UserBlockService } from './user-block.service';
interface VoiceParticipant {
channelId: string;
odlUserId: string;
userId: string;
joinedAt: Date;
muted: boolean;
deafened: boolean;
}
interface DmCall {
callId: string;
callerId: string;
calleeId: string;
channelId: string;
state: 'ringing' | 'active' | 'ended' | 'declined';
createdAt: Date;
}
// In-memory stores
const participants: VoiceParticipant[] = [];
const dmCalls: DmCall[] = [];
export class VoiceChannelService {
constructor(private userBlockService: UserBlockService) {}
/**
* Join a voice channel.
*
* For DM channels, checks blocking before allowing join.
*/
async joinVoiceChannel(
userId: string,
channelId: string,
channelType: 'dm' | 'project'
): Promise<void> {
// For DM channels, check blocking
if (channelType === 'dm') {
const otherUserId = this.getOtherDmUser(channelId, userId);
const isBlocked = await this.userBlockService.isBlockingEitherWay(userId, otherUserId);
if (isBlocked) {
throw new Error('Cannot join voice - user is blocked');
}
}
const participant: VoiceParticipant = {
channelId,
odlUserId: `odl_${userId}`,
userId,
joinedAt: new Date(),
muted: false,
deafened: false,
};
participants.push(participant);
BusVoiceParticipant.next({
channelId,
userId,
action: 'joined',
timestamp: participant.joinedAt,
});
}
/**
* Start a DM call.
*
* Checks blocking before creating the call.
*/
async startDmCall(callerId: string, calleeId: string): Promise<DmCall> {
// Check blocking
const isBlocked = await this.userBlockService.isBlockingEitherWay(callerId, calleeId);
if (isBlocked) {
throw new Error('Cannot start call - user is blocked');
}
const call: DmCall = {
callId: `call_${Date.now()}`,
callerId,
calleeId,
channelId: `dm_${callerId}_${calleeId}`,
state: 'ringing',
createdAt: new Date(),
};
dmCalls.push(call);
BusDmCall.next({
callId: call.callId,
callerId,
calleeId,
state: 'ringing',
timestamp: call.createdAt,
});
// Notify callee
this.notifyDmCall(call);
return call;
}
/**
* Answer a DM call.
*
* BUG: Missing blocking check!
* If block is created after call starts but before answer,
* the callee can still answer.
*/
async answerDmCall(callId: string, userId: string): Promise<void> {
const call = dmCalls.find(c => c.callId === callId);
if (!call) throw new Error('Call not found');
if (call.calleeId !== userId) throw new Error('Not the callee');
if (call.state !== 'ringing') throw new Error('Call is not ringing');
// BUG: No blocking check here!
// Should check: await this.userBlockService.isBlockingEitherWay(call.callerId, call.calleeId)
call.state = 'active';
BusDmCall.next({
callId: call.callId,
callerId: call.callerId,
calleeId: call.calleeId,
state: 'active',
timestamp: new Date(),
});
}
/**
* Decline a DM call.
*
* BUG: Missing blocking check!
*/
async declineDmCall(callId: string, userId: string): Promise<void> {
const call = dmCalls.find(c => c.callId === callId);
if (!call) throw new Error('Call not found');
if (call.calleeId !== userId) throw new Error('Not the callee');
// BUG: No blocking check here either!
call.state = 'declined';
BusDmCall.next({
callId: call.callId,
callerId: call.callerId,
calleeId: call.calleeId,
state: 'declined',
timestamp: new Date(),
});
}
/**
* End a DM call between two users.
*
* Used when block is created to clean up active calls.
*/
async endDmCallBetweenUsers(userA: string, userB: string): Promise<void> {
const call = dmCalls.find(
c =>
(c.callerId === userA && c.calleeId === userB) ||
(c.callerId === userB && c.calleeId === userA)
);
if (call && call.state !== 'ended') {
call.state = 'ended';
BusDmCall.next({
callId: call.callId,
callerId: call.callerId,
calleeId: call.calleeId,
state: 'ended',
timestamp: new Date(),
});
}
}
/**
* Kick a user from a voice channel.
*/
async leaveChannel(userId: string, channelId?: string): Promise<void> {
const index = participants.findIndex(
p => p.userId === userId && (!channelId || p.channelId === channelId)
);
if (index !== -1) {
const participant = participants[index];
participants.splice(index, 1);
BusVoiceParticipant.next({
channelId: participant.channelId,
userId,
action: 'left',
timestamp: new Date(),
});
}
}
/**
* Notify callee of incoming DM call.
*
* BUG: Doesn't filter for blocking!
* Blocked users still receive call notifications.
*/
private notifyDmCall(call: DmCall): void {
// BUG: Should check blocking before notifying
// if (await this.userBlockService.isBlockingEitherWay(call.callerId, call.calleeId)) return;
// Send notification to callee...
console.log(`[notify] ${call.calleeId}: Incoming call from ${call.callerId}`);
}
private getOtherDmUser(channelId: string, userId: string): string {
// Parse DM channel ID to get the other user
const parts = channelId.replace('dm_', '').split('_');
return parts.find(id => id !== userId) || '';
}
}