Campaign Sync Plan β Web Management UI & Discord Bot
Problem Statement
Campaigns created in the web management UI are invisible to Discord slash commands, and vice versa. A DM who creates a campaign at app.glyphoxa.com cannot select it via /campaign switch in Discord. A DM who uploads a campaign YAML in Discord doesnβt see it in the web dashboard.
The root cause: the web service and the gateway/Discord bot use completely separate data sources for campaigns β the web writes to mgmt.campaigns in PostgreSQL, while the gateway reads from YAML config files loaded at startup.
1. Current State Analysis
1.1 Web Management Service
| Aspect | Detail |
|---|---|
| Campaign store | mgmt.campaigns table (PostgreSQL) |
| CRUD | Full REST API: POST/GET/PUT/DELETE /api/v1/campaigns |
| Model | id, tenant_id, name, system, language, description, timestamps, soft-delete |
| Related data | Lore documents (mgmt.lore_documents), NPC links (mgmt.campaign_npcs), knowledge entities |
| Session start | Passes campaign_id to gateway via gRPC StartWebSession RPC |
| NPC management | Full CRUD per campaign, stored in mgmt schema |
| Source files | internal/web/handlers_campaigns.go, internal/web/store.go, internal/web/migrations/001_initial.sql |
1.2 Gateway / Discord Bot
| Aspect | Detail |
|---|---|
| Campaign store | config.CampaignConfig struct, loaded from YAML at startup |
| Tenant campaign | Tenant.CampaignID field exists in Go struct but is NOT persisted to the tenants PostgreSQL table (lost on gateway restart) |
| NPC loading | NPC configs read from YAML at startup, snapshot-sent to workers. The npc_definitions table exists but is used by full-mode entity store, not gateway-mode NPC loading |
/campaign info | Reads from YAML config β shows nothing if config is empty |
/campaign load | Uploads YAML and imports entities β full mode only, unavailable in gateway mode |
/campaign switch | Stub β responds βSwitched to campaign Xβ but doesnβt actually change anything. Autocomplete only shows the single YAML config campaign name |
| Session controller | GatewaySessionController.Start() uses gc.campaignID from construction time, ignores req.CampaignID from the web serviceβs StartWebSession request |
| Source files | internal/discord/commands/campaign.go, internal/gateway/sessionctrl.go, internal/gateway/adminstore_postgres.go, internal/gateway/admin.go |
1.3 Architecture Plan vs Reality
The architecture plan (01-architecture.md, Section 5) already prescribes the correct design:
| Concern | Web Management Service | Gateway | Shared (DB) |
|---|---|---|---|
| Campaign CRUD | Owns | Reads campaign context | campaigns table |
| NPC CRUD | Owns (HTTP) | Reads NPC defs at session start | npc_definitions table |
This hasnβt been implemented. The web service created its own mgmt.* tables, but the gateway was never updated to read from them. The two services evolved independently.
2. Gap Analysis
Gap 1: No Shared Campaign Source of Truth
- Web writes to
mgmt.campaigns(PostgreSQL,mgmtschema) - Gateway reads from
config.CampaignConfig(YAML file, loaded once at startup) - Neither reads from the other
Gap 2: Tenant campaign_id Not Persisted
The tenants table migration (000001_tenants.up.sql) has no campaign_id or dm_role_id columns:
CREATE TABLE IF NOT EXISTS tenants (
id TEXT PRIMARY KEY,
license_tier TEXT NOT NULL DEFAULT 'shared',
bot_token TEXT NOT NULL DEFAULT '',
guild_ids TEXT[] DEFAULT '{}',
monthly_session_hours NUMERIC(10,2) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
The PostgresAdminStore SQL (SELECT/INSERT/UPDATE) also excludes these fields. The Go struct fields exist but are in-memory ephemera.
Gap 3: /campaign switch Is a No-Op
// handleSwitch in campaign.go β responds but changes nothing
discordbot.RespondEphemeral(e, fmt.Sprintf("Switched to campaign **%s**.", name))
The autocomplete only returns the current YAML config name:
func (cc *CampaignCommands) autocompleteCampaignSwitch(...) {
cfg := cc.getCfg()
if cfg != nil && cfg.Name != "" {
choices = append(choices, ...) // Only one choice: current config
}
}
Gap 4: Web Campaign ID Ignored on Session Start
GatewaySessionController.Start() at sessionctrl.go:151:
sessionID, err := gc.orch.ValidateAndCreate(ctx, gc.tenantID, gc.campaignID, ...)
// ^^^^^^^^^^^^^^
// hardcoded from controller construction
// req.CampaignID is never used
The web service sends campaign_id in StartWebSession, but itβs silently discarded.
Gap 5: NPC Definitions Not Shared
- Web manages NPCs via REST API, stored in
mgmtschema tables - Gateway loads NPCs from YAML
cfg.NPCsat startup, converts togw.NPCConfigMsg, snapshots to workers - The
npc_definitionstable (innpcstorepackage) exists for the entity store but isnβt used by gateway-mode NPC loading - Same sync problem as campaigns
Gap 6: No Change Notification
When a DM creates/updates a campaign or NPC in the web UI, the gateway has no mechanism to learn about the change. No webhooks, no polling, no pub/sub.
3. Proposed Solution
3.1 Design Principles
mgmt.campaignsis the single source of truth β all campaign CRUD flows through the web serviceβs DB tables- Gateway reads from DB, not config β campaigns and NPCs loaded from PostgreSQL at session start, not from YAML
- YAML config becomes optional bootstrap β existing YAML campaigns can be imported into the DB as a migration path
- Minimal gateway changes β the gateway stays lean; the web service owns the data model
- PostgreSQL LISTEN/NOTIFY for real-time sync β lightweight, no external message broker needed
3.2 Architecture
βββββββββββββββββββββββββββ
β Web Management UI β
β (campaign/NPC CRUD) β
ββββββββββββββ¬βββββββββββββ
β REST API
βΌ
βββββββββββββββββββββββββββ
β Web Management Service β
β (owns mgmt.campaigns, β
β mgmt.lore_documents, β
β npc_definitions) β
ββββββββββββββ¬βββββββββββββ
β PostgreSQL writes +
β NOTIFY 'campaign_changed'
βΌ
βββββββββββββββββββββββββββ
β PostgreSQL β
β βββββββββββββββββββββ β
β β mgmt.campaigns β β
β β npc_definitions β β
β β tenants β β
β β sessions β β
β βββββββββββββββββββββ β
ββββββββββββββ¬βββββββββββββ
β DB read + LISTEN
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββ
β Gateway β
β ββββββββββββββββ ββββββββββββββββββββββββ β
β β BotManager β β CampaignReader β β
β β (per-tenant βββββ (reads mgmt.campaignsβ β
β β bots) β β + npc_definitions β β
β ββββββββ¬ββββββββ β on session start) β β
β β ββββββββββββββββββββββββ β
β βΌ β
β ββββββββββββββββ β
β βSessionCtrl β Uses req.CampaignID (not β
β β(per session) β hardcoded controller field) β
β ββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββΌβββββββββββββββββββββ
β Discord β β
β /campaign info ββ reads from DB β
β /campaign switch ββ lists DB campaigns, β
β updates tenant.campaign_id β
β /campaign load ββ writes to mgmt.campaigns β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
3.3 Detailed Changes
A. Persist campaign_id and dm_role_id on tenants table
New migration 000002_tenant_campaign.up.sql:
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS campaign_id TEXT NOT NULL DEFAULT '';
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS dm_role_id TEXT NOT NULL DEFAULT '';
Update PostgresAdminStore SQL queries (SELECT, INSERT, UPDATE) to include both columns. Update scanTenant() to scan them.
B. Gateway reads campaigns from mgmt.campaigns
Create a lightweight CampaignReader in the gateway that queries mgmt.campaigns:
// internal/gateway/campaignreader.go
type CampaignReader struct {
pool *pgxpool.Pool
}
func (r *CampaignReader) ListForTenant(ctx context.Context, tenantID string) ([]CampaignSummary, error) {
// SELECT id, name, system, language FROM mgmt.campaigns
// WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY name
}
func (r *CampaignReader) Get(ctx context.Context, tenantID, campaignID string) (*CampaignSummary, error) {
// SELECT id, name, system, language, description FROM mgmt.campaigns
// WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
}
The gateway reads but never writes mgmt.campaigns β ownership stays with the web service.
C. Gateway reads NPC definitions from DB at session start
Instead of loading NPCs from YAML config at startup and snapshotting them forever, the session controller queries npc_definitions when starting a session:
func (gc *GatewaySessionController) Start(ctx context.Context, req SessionStartRequest) error {
// Use the request's CampaignID (from web or Discord), not gc.campaignID
campaignID := req.CampaignID
if campaignID == "" {
campaignID = gc.campaignID // fallback to tenant default
}
// Load NPCs fresh from DB for this campaign
npcs, err := gc.npcStore.List(ctx, npcstore.ListOptions{CampaignID: campaignID})
// ... convert to NPCConfigMsg and send to worker
}
D. Fix /campaign switch to actually switch
func (cc *CampaignCommands) handleSwitch(e *events.ApplicationCommandInteractionCreate) {
// 1. List available campaigns for this tenant from DB
// 2. Validate the selected campaign exists
// 3. Update tenant.campaign_id in the tenants table
// 4. Reconnect the session controller with new campaign context
// 5. Respond with confirmation
}
func (cc *CampaignCommands) autocompleteCampaignSwitch(e *events.AutocompleteInteractionCreate) {
// Query mgmt.campaigns for this tenant, return as autocomplete choices
// Filter by partial input match
}
E. Fix /campaign load to write to DB (not just entity store)
When a DM uploads a YAML campaign via Discord:
- Parse the YAML as before
- Create a
mgmt.campaignsrecord (via a new gateway β web service internal API, or direct DB write) - Import entities into
npc_definitionstable - Set as the tenantβs active campaign
F. Fix /campaign info to read from DB
Instead of reading from config.CampaignConfig, read from mgmt.campaigns using the tenantβs campaign_id.
G. Session controller uses request CampaignID
In GatewaySessionController.Start(), use req.CampaignID instead of gc.campaignID:
// Before (broken):
sessionID, err := gc.orch.ValidateAndCreate(ctx, gc.tenantID, gc.campaignID, ...)
// After (fixed):
campaignID := req.CampaignID
if campaignID == "" {
campaignID = gc.campaignID // fallback to tenant's default
}
sessionID, err := gc.orch.ValidateAndCreate(ctx, gc.tenantID, campaignID, ...)
H. Optional: PostgreSQL LISTEN/NOTIFY for cache invalidation
If the gateway caches campaign/NPC data (e.g., for autocomplete responsiveness):
-- Web service issues after campaign CRUD:
NOTIFY campaign_changed, '{"tenant_id":"abc","campaign_id":"xyz","action":"updated"}';
Gateway listens and invalidates its cache. Not required for correctness (fresh DB reads on session start are sufficient) but improves UX for autocomplete and /campaign info.
3.4 YAML Config Deprecation Path
| Phase | YAML Config Role | DB Role |
|---|---|---|
| Now | Primary source for gateway campaigns | Web service only |
| Phase 1 (this plan) | Optional bootstrap / import source | Primary source for both |
| Phase 2 | Removed from gateway startup path | Sole source of truth |
YAML campaigns in existing glyphoxa.yaml configs are imported into the DB via a one-time migration command:
glyphoxa migrate-campaigns --config glyphoxa.yaml --tenant-id <id>
This reads campaign.* and npcs.* from the YAML, creates mgmt.campaigns and npc_definitions records, and sets the tenantβs default campaign_id.
4. Implementation Steps
Phase 1: Database Foundation (effort: S)
- Add tenant migration β
000002_tenant_campaign.up.sqlwithcampaign_idanddm_role_idcolumns - Update
PostgresAdminStoreβ include new columns in all SQL queries andscanTenant() - Add
CampaignReaderto gateway β read-only access tomgmt.campaigns - Tests β update admin store tests, add campaign reader tests
Phase 2: Session Controller Fix (effort: S)
- Fix
GatewaySessionController.Start()β usereq.CampaignIDwith fallback to tenant default - Load NPCs from DB at session start β query
npc_definitionsby campaign_id instead of using snapshot - Inject
npcStoreinto session controller β addnpcstore.Storedependency - Tests β update session controller tests, verify campaign_id flows through
Phase 3: Discord Command Overhaul (effort: M)
- Inject
CampaignReaderintoCampaignCommandsβ replacegetCfgdependency - Rewrite
/campaign infoβ read from DB instead of YAML config - Rewrite
/campaign switchβ list campaigns from DB, validate selection, update tenantcampaign_id, respond with embed - Rewrite autocomplete β query
mgmt.campaignsfor tenant, filter by partial input - Update
/campaign loadβ write parsed campaign tomgmt.campaigns+npc_definitions, set as active - Tests β table-driven tests for all command handlers
Phase 4: Migration Tooling (effort: S)
migrate-campaignsCLI command β imports YAML config campaigns into DB- Deprecation warnings β log warning if
campaign.*config is set in YAML, pointing to web UI - Documentation update β update README and deployment docs
Phase 5: Optional Enhancements (effort: S)
- LISTEN/NOTIFY β web service emits notifications on campaign/NPC changes
- Gateway cache β lightweight in-memory cache with NOTIFY-driven invalidation
/campaign createDiscord command β create campaigns directly from Discord (writes to DB via gateway β web API or direct write)
5. NPC Sync β Same Pattern
The NPC definition sync follows the exact same pattern as campaigns. The web service already manages NPCs in the mgmt schema. The changes needed:
- Gateway reads
npc_definitionsfrom DB at session start β thenpcstore.PostgresStorealready exists and supportsList(ctx, ListOptions{CampaignID: id}) - Discord
/npccommands read/write from DB β instead of in-memory-only NPC state - YAML NPC config becomes optional bootstrap β same migration path as campaigns
This is addressed in Phase 2, steps 6-7 above. The npcstore package is already well-designed for this β it just needs to be wired into the gateway session startup path.
6. Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Schema migration on live DB | Medium | High β tenants table is gateway-critical | Use IF NOT EXISTS / ADD COLUMN IF NOT EXISTS. Default values prevent null issues. Test migration on staging first. |
| Cross-schema reads (gateway β mgmt) | Low | Medium β if mgmt schema doesnβt exist on gateway-only deployments | Gatewayβs CampaignReader handles missing schema gracefully (returns empty list). |
| Performance of DB reads on session start | Low | Low β single SELECT with index | idx_mgmt_campaigns_tenant already exists. NPC list query is indexed by campaign_id. Sub-millisecond. |
| Backward compatibility | Medium | Medium β existing YAML-based deployments break | Phase 4 migration tool + deprecation warnings provide smooth transition. YAML config continues working during transition (fallback to config if DB is empty). |
| Concurrent campaign switches | Low | Low β one campaign per tenant at a time | Tenant update is atomic (single UPDATE). Session start checks campaign exists. |
| Gateway restart loses active campaign | Previously High (not persisted) | Resolved by persisting campaign_id in tenants table | Phase 1, step 1-2. |
7. Out of Scope
- Real-time campaign collaboration (multiple DMs editing simultaneously) β future feature
- Campaign versioning / history β can be added later with an audit trail
- Cross-tenant campaign sharing / templates β separate feature
- Campaign import from VTTs (Foundry, Roll20) β existing VTT import is entity-level, not campaign-level
- Billing integration (campaign limits per tier) β tracked separately in the billing plan