The blocker is identity, not Discord commands.
The current implementation routes Discord components, HTTP requests, WebSocket topics, guild state, Lavalink players, timers, votes, and cyphers through guild identity.
Current and target topology
Toggle the system view. The migration preserves the existing single-player path as an explicit compatibility assignment instead of rewriting playback wholesale.
Five state collisions define the refactor.
The core work is extracting ownership from the guild aggregate while keeping current single-player behavior available through a compatibility projection.
Profile policy
implicit guild settings→VoiceProfile · revisionIdentity capacity
static bot IDs→IdentityRecord + LeaseTransport
module globals · guildId→PlayerAgent-local stateControl context
guild / current voice→InteractionContextOne BotServer per guild
Approved channel, queue, vote, cypher, and autopause are one shared bundle.
src/models/Server.ts:72Guild-keyed adapter state
Player, snapshot, and seek maps collide; join returns the existing guild player.
src/services/lavalink/index.ts:80,318Guild-keyed controller maps
Readiness, player, voice, and recovery state cannot represent two identities independently.
src/services/lavalink/PlayerController.ts:33Guild-only control routing
Components, HTTP, and WebSocket topics cannot select one concurrent session.
src/menus/handler.ts:59 · src/server/websocket.ts:45Same-channel cyphers
Muting and autopause operate on a singular Discord voice channel. Independent cyphers sharing one call need an explicit product rule.
src/cogs/CypherRotation.ts:35Repair the foundation before adding capacity.
These issues already exist in the current single-player system. Multi-identity orchestration would amplify them, so they are prerequisites, not scope creep.
HTTP authorization
Arbitrary bearer-shaped values and client-selected X-User-ID are accepted; role enforcement is not mounted across guild routes.
WebSocket authorization
Caller-selected guild subscriptions lack demonstrated handshake and topic authorization.
src/server/websocket.ts:17,45Durable mutation boundary
Routes directly mutate serializable fields while scheduled persistence saves only dirty servers.
src/server/routes/settings.ts:39 · PersistenceService.ts:137Typed startup restoration
Reconnect precedes hydration; restored channel IDs are not resolved into the typed live references playback consumes.
src/index.ts:264 · Serialization.ts:113Lifecycle parity
Dashboard and Discord controls take different paths for skip, cypher start, and voice disconnect.
routes/musicplayer.ts:127 · menus/actions.ts:169Recovery coordination
Controller and MusicPlayer can both react to a close without one coordinator or activation fence.
PlayerController.ts:155 · MusicPlayer.ts:171BarAPI schema parity
BarBot serializes fields not declared by the BarAPI record model, risking silent loss on round-trip.
Serialization.ts:77 · BarAPI/Models/discord_models.py:90Pure resolution
Looking up a guild server starts background refill, coupling state resolution to lifecycle side effects.
src/services/Helpers.ts:333Where identity is selected today.
The same hidden assumption appears in startup, Discord components, dashboard routes, WebSocket subscriptions, and recovery. This table identifies the exact resolution point and the seam that replaces it.
Serialization.ts:113-120Voice reconnect runs before persisted server state is hydrated. Channel IDs are restored into side-channel fields, not resolved live channel references. Seam: hydrate, resolve, then reconcile desired assignments.
customId selects an action, then interaction.guild selects the BotServer. Seam: opaque contextRef resolves an InteractionContext before handler dispatch.menus/actions.ts:176-182The approved voice channel is durable policy, live target, and permission check at once. Seam: ChannelAssignment owns the target; member voice can only permit or deny.
websocket.ts:17-54Caller-provided identity and guild subscriptions can reach guild-scoped state. Seam: verified actor claims plus authorized assignment/session topics.
menus/actions.ts:169-201Dashboard skip stops the player; Discord skip may advance playback or start a vote. Seam: one lifecycle command service used by every control surface.
MusicPlayer.ts:171-190Controller and cog both react to a transport close. Seam: one RecoveryCoordinator per session, fenced by activation epoch.
sequenceDiagram
participant U as Discord / HTTP / WS
participant R as Interaction Resolver
participant A as Assignment Store
participant P as PlayerAgent
participant S as RuntimeSession
U->>R: mutation + contextRef
R->>A: resolve assignmentId
A-->>R: sessionId, identityId, activationEpoch
R->>R: authorize actor, guild, surface, epoch
R->>P: command(sessionId, expectedEpoch)
P->>S: apply only when epoch matches
S-->>U: event + assignmentId + sessionId + epoch + seq
Note over R,S: Member voice may deny an unsafe action. It never retargets it.
Six boundaries, three routing keys.
This model matches the product: manual assignment, occupancy-triggered profiles, scheduled behavior, reserve-backed dynamic channels, and one command surface coordinating several identities.
sessionId
expectedEpoch
- actor claims
- surface
- voice safety
- channel binding
- capacity mode
- lifecycle
- occupancy
- empty policy
- schedule
- exclusive claim
- heartbeat
- activation epoch
- Discord client
- Lavalink client
- local caches
- queue + vote
- cypher + timers
- recovery gate
Concrete records and control contracts.
Everything below is a proposed additive contract, not a claim about current implementation. The names make ownership testable and give M1 through M3 a stable target.
erDiagram
GUILD ||--o{ VOICE_PROFILE : owns
GUILD ||--o{ CHANNEL_ASSIGNMENT : owns
VOICE_PROFILE ||--o{ CHANNEL_ASSIGNMENT : configures
CHANNEL_ASSIGNMENT ||--o| RUNTIME_SESSION : activates
IDENTITY_RECORD ||--o{ IDENTITY_LEASE : grants
IDENTITY_LEASE ||--|| RUNTIME_SESSION : fences
PLAYER_AGENT ||--o{ RUNTIME_SESSION : hosts
VOICE_PROFILE {
string voiceProfileId PK
string guildId
int revision
bool enabled
json occupancyPolicy
json schedulePolicy
}
CHANNEL_ASSIGNMENT {
string assignmentId PK
string guildId
string voiceChannelId
string voiceProfileId FK
string capacityMode
string lifecycle
}
IDENTITY_RECORD {
string identityId PK
string credentialRef
string health
string capacityState
}
IDENTITY_LEASE {
string leaseId PK
string identityId FK
string assignmentId FK
int activationEpoch
datetime expiresAt
}
RUNTIME_SESSION {
string sessionId PK
string assignmentId FK
string identityId FK
int activationEpoch
string state
}
interface ChannelAssignment {
assignmentId: string;
guildId: string;
voiceChannelId: string;
voiceProfileId: string;
capacityMode: "fixed" | "reserve";
lifecycle: AssignmentState;
}
interface RuntimeSession {
sessionId: string;
assignmentId: string;
identityId: string;
leaseId: string;
activationEpoch: number;
state: SessionState;
}Queue, vote, cypher, timers, recovery, and observed transport belong to RuntimeSession, not ChannelAssignment.
interface InteractionContext {
actorId: string;
actorClaims: ActorClaims;
guildId: string;
assignmentId: string;
sessionId: string;
expectedEpoch: number;
surface: "discord" | "http" | "ws";
memberVoiceChannelId?: string;
}The member voice field is a safety input. It cannot choose or replace assignmentId or sessionId.
bb:<contextRef>:<action>
contextRef -> {
assignmentId,
sessionId,
expectedEpoch,
actorScope,
expiresAt
}Use a short opaque reference with a server-side TTL record. Do not pack the complete routing context into the component ID.
{
"type": "session.playback.changed",
"guildId": "...",
"assignmentId": "...",
"sessionId": "...",
"activationEpoch": 14,
"seq": 208,
"data": { "state": "playing" }
}Consumers discard lower epochs and non-monotonic sequence values. Subscription authorization is checked against assignment/session scope.
| Proposed route | Purpose | Required context | Legacy behavior |
|---|---|---|---|
POST /api/guilds/:guildId/assignments | Bind a profile and channel | Verified actor + admin/role claim | No direct legacy equivalent |
PATCH /api/guilds/:guildId/assignments/:assignmentId | Change profile, capacity, or lifecycle intent | assignmentId + expected revision | Settings routes become wrappers |
POST .../:assignmentId/activate | Claim lease and create session | expected assignment revision | Join route resolves compatibility assignment |
POST .../:assignmentId/deactivate | Drain session and release lease | sessionId + expectedEpoch | Disconnect route awaits command completion |
GET /api/guilds/:guildId/identities | Inventory, health, and capacity | Verified operator scope | Configured bot IDs remain read-only migration input |
Lease first, session second, every callback fenced.
The activation epoch is not bookkeeping. It is the mechanism that makes delayed timers, reconnect callbacks, and WebSocket events harmless after ownership changes.
stateDiagram-v2
direction LR
[*] --> Draft
Draft --> Bound: validated
Bound --> Activating: triggered
Activating --> Active: ready
Activating --> Error: failed
Active --> Draining: deactivate
Active --> Fenced: superseded
Draining --> Inactive: released
Fenced --> Inactive: stale work dropped
Error --> Bound: retry
Inactive --> Activating: desired active
Start with one Discord identity per process.
This is an implementation recommendation with a deliberate reversal point. It reduces the M2 blast radius while duplicate-guild Lavalink behavior remains a runtime unknown.
| Concern | One identity per process | Several clients per process |
|---|---|---|
| State isolation | Process boundary naturally contains client, adapter, controller, cache, and failure state. | Every singleton and module-global map must be made instance-safe before the second client starts. |
| Operational cost | More processes and health checks; simple ownership and restart semantics. | Fewer processes; denser memory use and larger shared blast radius. |
| Migration fit | Preserves current one-client startup inside each PlayerAgent worker. | Requires startup and dependency injection refactor during transport isolation. |
| Lavalink unknown | Can test separate identity-local clients against one or separate nodes. | Still requires proof that the deployed client/node combination distinguishes duplicate guild players. |
| Failure recovery | Restart one identity without disturbing siblings. | Client or event-loop faults may disturb every identity in the process. |
| Initial verdict | Recommended for M2 | Revisit only after measured memory and operations pressure |
Start two identity-local clients for the same guild, join distinct channels where Discord permits it, play independent tracks, then verify player keys, voice updates, reconnects, leave behavior, and event attribution. Repeat against the exact deployed Lavalink and Shoukaku versions.
Add seams before moving behavior.
The sequence separates existing correctness repairs from product capability, then preserves the old route until evidence supports cutover.
Repair
Auth, schema parity, startup, lifecycle operations, pure lookup, one recovery owner.
Model
Add profiles, assignments, identity inventory, leases, and a legacy compatibility projection.
Isolate
Instance-scope adapter state; introduce PlayerAgent and RuntimeSession; retain one-agent startup.
Route
Assignment/session controls, opaque component context, authenticated session topics, guild wrappers.
Orchestrate
Occupancy, schedules, reserve leasing, dynamic channels, dashboard identity/capacity management.
The smallest meaningful edit surface
These are the primary seams, not every downstream callsite. The highest-disruption work is confined to transport state isolation in M2.
| Phase | File / symbol | Current responsibility | Required change | Disruption |
|---|---|---|---|---|
| M1 | models/Server.ts:60-104 BotServer | Guild runtime bundle | Keep as compatibility projection; move queue, vote, cypher, and timers into RuntimeSession | Medium |
| P0 / M1 | services/Serialization.ts:76-120 | Legacy durable server shape and ID side channels | Versioned profiles, assignments, inventory, and leases; resolve typed channels before reconcile | Medium |
| P0 / M1 | services/PersistenceService.ts:48-85,137+ | Bulk hydrate and dirty-only sync | One mutation boundary marks durable aggregates dirty and writes schema-versioned records | Medium |
| M2 | services/lavalink/index.ts:94-96,316-394 activePlayers · joinChannel | Module-global snapshot, seek, warning, and player registries | Construct an AdapterManager per PlayerAgent; key live state by session inside that owner | High |
| M2 | lavalink/PlayerController.ts:33-38,150-195 | Guild-keyed transport/controller and recovery | PlayerAgent-local controller; delegate recovery decisions to session coordinator | High |
| P0 / M2 | index.ts:266-275,302-313 | One client, one Lavalink binding, reconnect before hydrate | Hydrate-first bootstrap; preserve this startup inside one-identity PlayerAgent workers | Medium |
| M3 | menus/handler.ts:55-71 | Guild-scoped component routing | Resolve opaque contextRef, authorize, validate epoch, then dispatch | Medium |
| P0 / M3 | server/middleware.ts:17-47 | API key, arbitrary bearer, or caller user ID | Verified credential to ActorClaims; remove caller-selected identity trust | High |
| P0 / M3 | server/websocket.ts:17-74 | Unauthenticated guild-topic subscriptions | Authenticated assignment/session topics with epoch and sequence envelope | Medium |
| P0 / M3 | routes/musicplayer.ts:127-140 menus/actions.ts:169-201 | Divergent skip lifecycle | Route both surfaces through one session command service | Medium |
| P0 | routes/voice.ts:59-62 | Fire-and-forget disconnect | Await deactivation and expose drain failure | Medium |
| P0 | services/Helpers.ts:345-363 | Resolution starts queue background refill | Make lookup pure; move prewarm to explicit lifecycle hook | Low |
Evidence gates, not hopeful milestones
The audit was static by design. These are the executable gates that convert architectural claims into deployment confidence.
Authorization
- forged bearer denied
- forged identity denied
- wrong guild/role denied
- unauthorized WS denied
Startup restoration
- hydrate before reconcile
- resolve typed channels
- idempotent reconciliation
Lifecycle parity
- menu/API skip match
- cypher start match
- disconnect awaits leave
Same-guild sessions
- players + queues isolated
- votes + timers isolated
- stale epochs rejected
Explicit routing
- actor movement never retargets
- voice context may only deny
Profiles + capacity
- lease contention deterministic
- crash cleanup releases
- schedules reconcile
- dynamic cleanup audited
The architecture is clear; these policies are not.
The final status is human-decision-required because product and operations choices determine lease semantics, process topology, and dynamic-channel behavior.
What survived the model panel
Kimi, GLM, and DeepSeek attacked the Terra draft; two further Terra agents verified the challenges against source.
X-User-ID paths remain accepted.