Files
campaign-manager/campaign_manager_design_v7.md

113 KiB

TaleSpire

Campaign Manager Symbiote

System Design & Architecture

v0.1 · March 2026

This document describes the full system design for the Campaign Manager Symbiote --- a TaleSpire mod that provides authenticated, multi-group campaign management with ruleset-agnostic character sheets and integrated dice rolling.

1. Overview

The Campaign Manager Symbiote is a full-featured campaign and character management tool embedded directly into TaleSpire. It allows players and Game Masters to manage groups, characters, and session data from within the game --- eliminating the need for external tools during play.

1.1 Goals

  • Authenticated multi-user accounts with persistent data

  • Flexible group model: one player → many groups, one group → many players + one DM

  • Character portability: one character can appear in multiple groups or adventures

  • Ruleset abstraction: a plugin-based system that supports D&D 5e today and other systems tomorrow

  • Native TaleSpire dice integration via the TS API

  • Works entirely inside TaleSpire with no Alt+Tab required

1.2 Non-Goals (v1)

  • Board state editing (placing tiles, moving minis) --- out of scope per TaleSpire API limits

  • Real-time collaborative editing of the same character sheet simultaneously

  • Mobile or standalone web app (Symbiote-only for now)

2. System Architecture

The system is split into three layers: the TaleSpire Symbiote (frontend), a backend REST + WebSocket API, and a persistent database. Because Symbiotes run in Chromium, the frontend is standard web tech (HTML/CSS/JS or a bundled React app).

2.1 High-Level Diagram

+-----------------------------------+-----------------------------------+ | TaleSpire Symbiote (Chromium) | Backend Service | +-----------------------------------+-----------------------------------+ | • Auth UI (login / register) | • Auth Service (JWT + refresh | | | tokens) | | • Group Dashboard | | | | • Groups & Membership API | | • Character Sheet Renderer | | | | • Characters API | | • Ruleset Plugin Loader | | | | • Ruleset Registry | | • Dice Integration (TS API) | | | | • Dice History Store | | • TS.sync for live updates | | | | • PostgreSQL + Redis | +-----------------------------------+-----------------------------------+

The Symbiote communicates with the backend over HTTPS. For live session features (dice results visible to all group members) it upgrades to a WebSocket connection authenticated with the same JWT.

2.2 Symbiote Setup

The manifest.json declares the entry point, enables the TS API (v0.1), and requests the colorStyles, fonts, and diceFinder extras so the UI matches TaleSpire's look and dice detection works on any page inside the Symbiote.

{ "apiVersion": "v0.1",

"extras": ["colorStyles", "fonts", "diceFinder"],

"interopId": "com.yourproject.campaign-manager",

"runInBackground": false,

"initializeEarly": false }

Because board state can shift during play, the Symbiote listens for client leave/join events and suppresses API calls during board transitions, in line with the Symbiote lifetime guidelines.

3. Authentication

Authentication uses short-lived JWT access tokens (15 min) paired with long-lived refresh tokens (30 days) stored in the Symbiote's TS storage (not localStorage, which is unreliable across Symbiote restarts).

3.1 Registration & Login Flow

  • User enters email + password in the Symbiote login screen

  • POST /auth/register or POST /auth/login → returns { accessToken, refreshToken, user }

  • Tokens persisted via TS.storage so they survive Symbiote restarts

  • On boot, Symbiote checks stored tokens; if access token expired, silently refreshes via POST /auth/refresh

  • On 401 from any API call, same silent refresh is attempted before surfacing an error

3.2 Token Storage

TaleSpire's TS.storage API is used as the persistence layer. This is per-installation, meaning tokens are not shared between the mod-downloader install and any manual install of the same Symbiote (consistent with TaleSpire's storage separation rules).

3.3 Security Considerations

  • HTTPS required for all backend communication

  • Passwords hashed server-side with bcrypt (cost factor ≥ 12)

  • Refresh tokens are single-use and rotated on every refresh

  • Rate limiting on auth endpoints: 10 requests / minute per IP

  • Email verification required before joining or creating groups

4. Data Model

The schema is designed around the following cardinalities, which are enforced at the API level:

  • 1 Player → N Groups (via membership)

  • 1 Group → N Players + 1 DM

  • 1 Player → N Characters per Group

  • 1 Character → N Groups (portable across adventures)

4.1 users


Field Type Required Description


id UUID (PK) Yes Unique user identifier

email VARCHAR(255) Yes Unique, used for login

password_hash TEXT Yes bcrypt hash, never returned by API

display_name VARCHAR(100) Yes Shown in group member lists

email_verified BOOLEAN Yes Must be true to join/create groups

created_at TIMESTAMPTZ Yes Account creation timestamp

4.2 groups


Field Type Required Description


id UUID (PK) Yes Unique group identifier

name VARCHAR(150) Yes Campaign or adventure name

dm_user_id UUID (FK → Yes Exactly one DM per users) group

ruleset_id VARCHAR(100) Yes e.g. 'dnd5e', 'pathfinder2e'

description TEXT No Campaign blurb / notes

created_at TIMESTAMPTZ Yes

4.3 group_members (join table)


Field Type Required Description


group_id UUID (FK → Yes Composite PK with groups) user_id

user_id UUID (FK → Yes Composite PK with users) group_id

role ENUM(player, dm) Yes DM is also stored here for query convenience

joined_at TIMESTAMPTZ Yes

4.4 characters


Field Type Required Description


id UUID (PK) Yes

owner_user_id UUID (FK → Yes The player who owns users) this character

ruleset_id VARCHAR(100) Yes Must match group ruleset when assigned

name VARCHAR(150) Yes Character name

sheet_data JSONB Yes Ruleset-specific stats blob (see §6)

portrait_url TEXT No Optional character portrait image URL

created_at TIMESTAMPTZ Yes

updated_at TIMESTAMPTZ Yes Updated on any sheet_data change

4.5 group_characters (assignment table)


Field Type Required Description


group_id UUID (FK → Yes Composite PK groups)

character_id UUID (FK → Yes Composite PK characters)

user_id UUID (FK → Yes Which player brought users) this character

is_active BOOLEAN Yes false = retired / benched for this campaign

assigned_at TIMESTAMPTZ Yes

A character can be assigned to multiple groups simultaneously (one row per group_characters). The owner retains full editing rights regardless of which groups the character is in.

5. REST API Reference

Base URL: https://api.campaign-manager.example.com/v1 All endpoints require Authorization: Bearer <accessToken> unless marked [public].

5.1 Auth


Method + Auth Description Path


POST Public Create a new user account /auth/register

POST /auth/login Public Authenticate; returns JWT pair

POST Refresh Rotate refresh token; issue new access token /auth/refresh token

POST Bearer Revoke refresh token /auth/logout

5.2 Groups


Method + Path Description


GET /groups List all groups the authenticated user is a member of (any role)

POST /groups Create a new group; caller becomes DM automatically

GET /groups/:id Get group detail including member list

PATCH /groups/:id Update name/description/ruleset (DM only)

DELETE /groups/:id Delete group (DM only); cascades membership & character assignments

POST /groups/:id/invite DM invites a player by email; creates a pending invite

POST /groups/:id/join/:token Player accepts an invite link

DELETE Remove player from group (DM or self-leave) /groups/:id/members/:userId

5.3 Characters


Method + Path Description


GET /characters List all characters owned by the authenticated user

POST /characters Create a new character (owner = caller)

GET /characters/:id Get full character including sheet_data

PUT /characters/:id Full sheet update; bumps updated_at

PATCH /characters/:id Partial update (e.g. HP change only)

DELETE /characters/:id Delete character; removes all group assignments

POST /groups/:id/characters Assign an owned character to this group

DELETE Unassign character from group /groups/:gid/characters/:cid

GET /groups/:id/characters List all characters assigned to this group (DM sees all; player sees own)

5.4 Dice History


Method + Path Description


POST Log a dice roll result (called automatically by /groups/:id/rolls Symbiote)

GET Retrieve recent roll history for a group session /groups/:id/rolls

6. Ruleset Abstraction Layer

The game logic is completely isolated behind a Ruleset interface. Each supported game system ships as a ruleset plugin --- a self-contained JS module that implements a fixed interface. The core Symbiote code never touches system-specific rules directly.

6.1 Ruleset Interface

Every ruleset plugin must export the following contract:

interface Ruleset {

id: string // e.g. 'dnd5e'

name: string // e.g. 'D&D 5th Edition'

version: string // semver

defaultSheet(): SheetData // blank character template

validate(sheet: SheetData): ValidationResult

getStatBlocks(sheet: SheetData): StatBlock[]

getDiceActions(sheet: SheetData): DiceAction[]

renderSheet(sheet: SheetData): VNode // framework-agnostic VDOM

}

6.2 SheetData

sheet_data in the database is an opaque JSONB blob from the backend's perspective. The ruleset plugin is the only code that reads or writes it. This means adding a new ruleset requires zero database migrations --- only a new plugin.

6.3 DiceAction

Rulesets expose dice actions so the core Symbiote can render roll buttons without knowing the game system. Each action produces a TaleSpire-compatible dice string:

interface DiceAction {

label: string // 'Attack Roll', 'Perception Check', etc.

diceString: string // TaleSpire format: '1d20+5', '2d6', etc.

category: string // 'attack' | 'skill' | 'save' | 'damage' | 'custom'

}

6.4 Bundled Rulesets (v1)

  • dsa5e --- Das Schwarze Auge 5. Edition (The Dark Eye 5e): attributes (MU, KL, IN, CH, FF, GE, KO, KK), derived values (LeP, AsP, KaP), talents, combat techniques, special abilities, and liturgical/spell pages

  • generic --- A minimal fallback: name, HP, arbitrary key-value stat pairs. Useful for one-shots or homebrew systems not yet covered by a plugin.

Future rulesets (D&D 5e, Pathfinder 2e, Call of Cthulhu, etc.) are added by dropping a new plugin into the rulesets/ directory and registering its id --- zero database migrations required.

7. Dice Integration

Dice integration uses two complementary TaleSpire mechanisms: the programmatic dice API and the diceFinder extra.

7.1 Programmatic Rolls (Character Sheet Buttons)

When a player clicks a dice action button on their character sheet, the Symbiote calls the TaleSpire dice API directly with the dice string produced by the ruleset plugin:

const action = ruleset.getDiceActions(sheet).find(a => a.label === 'Attack Roll');

await TS.dice.putDiceInHand(action.diceString);

This puts dice into the player's hand exactly as if they had dragged them from the dice tray, preserving the native TaleSpire roll experience.

7.2 diceFinder (External Pages)

When the Symbiote is used to load an external reference site (e.g. a spell compendium), the diceFinder extra automatically detects text that looks like a dice roll and makes it clickable --- no extra code required.

7.3 Roll History

After a roll resolves, the Symbiote listens on the TS dice event source and logs the result to the backend (POST /groups/:id/rolls). This gives the DM a real-time roll history panel during the session, and players can see their own recent rolls on the character sheet.

TS.dice.onDiceResult.addListener(async (result) => {

await api.post(`/groups/${currentGroup.id}/rolls`, {

character_id: activeCharacter.id,

dice_string: result.diceString,

total: result.total,

breakdown: result.breakdown,

});

});

8. Live Session Sync

For features that need all group members to see updates in near real-time (HP changes, roll history), the Symbiote uses a combination of TaleSpire's own TS.sync and a backend WebSocket channel.

8.1 TS.sync (same Symbiote, different clients)

For lightweight state (e.g. 'player X rolled initiative'), TS.sync messages are broadcast to all clients running the same Symbiote in the same board. This requires no backend infrastructure and has very low latency.

8.2 Backend WebSocket (persistent, cross-session)

For data that must persist (HP updates, saved rolls), the Symbiote also sends changes to the backend REST API. The backend can then push updates to other connected clients via a WebSocket room keyed to the group ID.

8.3 Conflict Resolution

Last-write-wins is used for most character fields. For HP specifically, delta updates ('+5 HP' rather than 'set HP to 45') are preferred to reduce merge conflicts during fast-paced combat rounds.

9. Symbiote Frontend Structure

The Symbiote is built as a single-page application (no navigation between pages, as recommended by TaleSpire to avoid losing the injected API). Views are swapped programmatically.

9.1 View Routing

  • Login / Register → shown when no valid tokens in TS.storage

  • Group List → home screen after login

  • Group Detail → member list, character roster, DM tools

  • Character Sheet → full sheet view with dice buttons

  • Character Select → modal to assign an existing character to a group

  • Roll History → side panel, always accessible during a session

9.2 Styling

The colorStyles extra is loaded so all colors reference TaleSpire CSS variables (--ts-background-primary, --ts-color-primary, etc.), keeping the UI consistent with the game's theme and automatically supporting future high-contrast modes.

9.3 Icons & Fonts

The fonts extra provides OptimusPrinceps for headings (matching TaleSpire's fantasy aesthetic) while body text uses a clean sans-serif. The icons extra provides ts-icon-* classes for action buttons (dice, edit, delete) without requiring external icon libraries.

10. Recommended Tech Stack


Layer Technology Rationale


Symbiote UI Svelte Reactive, minimal bundle; no virtual DOM overhead inside Chromium

Auth JWT Stateless tokens; argon2id (jsonwebtoken) + preferred over bcrypt for new argon2 systems

Backend Rust + Axum Memory-safe, high-throughput; strong async story with Tokio

ORM / Queries sqlx Compile-time checked queries against PostgreSQL; no ORM magic

Database PostgreSQL 16 JSONB for sheet_data; strong relational integrity; no PVC needed in k8s

Object Storage S3-compatible (e.g. Portrait images and .cmchar Cloudflare R2) exports; avoids PVC entirely in Kubernetes

Hosting Kubernetes (no PVC) Stateless pods; all persistence in Postgres + S3

11. Companion Web App

The companion web app (campaign-manager.example.com) extends the Campaign Manager beyond TaleSpire. Players can create and edit characters, import them from external tools, and manage their group memberships from any browser --- before or between sessions --- without TaleSpire open. The Symbiote and web app share the same backend API and the same user accounts; there is no separate auth system.

11.1 Scope

+-----------------------------------+-----------------------------------+ | In Scope (Web App) | Symbiote Only | +===================================+===================================+ | Create and edit characters (full | Live dice rolling via TS API | | sheet) | | | | Real-time roll history during | | Import characters from JSON / | session | | .cmchar file | | | | TS.sync group communication | | Manual character entry by ruleset | | | | Board-aware player/GM detection | | Browse and manage groups | | | | | | Invite players and assign | | | characters | | | | | | Export characters as .cmchar JSON | | +-----------------------------------+-----------------------------------+

11.2 Authentication

The web app uses the same /auth/* endpoints as the Symbiote. Access tokens are held in memory only; refresh tokens are stored in an HttpOnly, Secure, SameSite=Strict cookie --- more appropriate than TS.storage (which is Symbiote-specific) and safer than localStorage.

  • Login and register pages are fully public routes

  • All other routes are protected; unauthenticated requests redirect to /login

  • On hard refresh, the app silently calls POST /auth/refresh via the cookie before rendering any protected page

  • Logout revokes the refresh token server-side and clears the cookie, then redirects to /login

11.3 Character Import

The import flow accepts two sources: the Campaign Manager's own .cmchar export format, and generic JSON from any external tool via a field mapper. PDF, D&D Beyond, and other integrations are explicitly out of scope for v1.

11.3.1 Campaign Manager .cmchar Format

Any character exported from the web app or Symbiote produces a .cmchar file --- JSON with a fixed envelope. Importing one requires no field mapping:

{

"cm_version": "1.0",

"ruleset_id": "dnd5e",

"name": "Aelindra Swiftwind",

"sheet_data": { /* ruleset-specific blob */ }

}

The backend validates ruleset_id against the known registry, then passes sheet_data through the ruleset plugin validate() call. Field-level errors are surfaced in the UI before any record is created.

11.3.2 Generic JSON Import (Field Mapper)

For exports from Roll20, Foundry VTT, or custom spreadsheets, the web app provides a two-column field mapper. The user uploads any JSON file; the left column shows detected source fields, the right shows Campaign Manager target fields for the chosen ruleset. The user connects them by drag or dropdown, previews the mapped result, and confirms.

  • Required target fields (name, ruleset) must be mapped before import is allowed

  • Unmapped optional fields are silently dropped --- no partial saves are blocked

  • A completed mapping can be saved as a named template for re-use across future imports of the same tool's exports

  • The same ruleset validate() call runs on the mapped result before the character record is created

11.3.3 Manual Entry

A blank character is created by selecting a ruleset from a dropdown, which immediately renders the empty sheet form. The form is produced by the same ruleset plugin renderSheet() method used in the Symbiote, so both surfaces are always in sync. Auto-save drafts to the backend every 30 seconds to protect against accidental tab closure.

11.4 Character Editor

The full character editor is a single-page form rendered by the active ruleset plugin. Saving issues a PUT /characters/:id. Because both clients share the backend, a character saved on the web app is immediately available inside TaleSpire --- the Symbiote re-fetches on load or on receiving a WebSocket push.

  • Optimistic UI: changes are reflected locally immediately; on save failure the previous value is restored with a toast notification

  • Edit history: the backend retains the last 10 versions of sheet_data per character, accessible via GET /characters/:id/history, so accidental overwrites can be undone

  • Portrait upload: drag-and-drop image accepted, validated for MIME type and size (max 2 MB) client-side, then uploaded directly to object storage via a backend-issued presigned URL; the resulting URL is stored on the character record

11.5 Group Management

The group dashboard gives DMs and players a comfortable full-screen view of their campaigns. Pages and their access rules are:


Route Access Description


/groups All Card grid of every group the user belongs to; create new group button

/groups/:id Member Member list with roles, character roster per player, pending invites

/groups/:id/settings DM only Rename, change ruleset, transfer DM role, delete group

/groups/:id/invite DM only Generate a shareable link or send invite directly by email

/characters Owner Full character library; import, create, export, or delete characters

/characters/:id Owner Full character editor rendered by the ruleset plugin

/characters/:id/export Owner Download the character as a .cmchar JSON file

11.6 Tech Stack


Layer Technology Rationale


Framework Svelte + Vite Same component model as the Symbiote UI; shared ruleset plugin code

Styling CSS custom Mirror the TS colorStyles variables; properties both UIs stay visually consistent

Auth state Svelte store + No token ever touches JS memory HttpOnly cookie beyond the active session

API layer Centralized fetch Shared with Symbiote wrapper; client auto-refresh on 401

File handling File API + JSON parsed client-side before FileReader upload; no server-side temp file storage

Image upload Presigned S3 URL Client uploads direct to (no PVC) S3-compatible storage; backend stores URL only; k8s pods stay stateless

Hosting Static CDN + Web app is a static build; shares existing API the Axum API server with the Symbiote

11.7 CORS & Security

  • Backend CORS allowlist: the web app domain and localhost:8080 (Symbiote dev mode). No wildcard origins.

  • CSRF protection via SameSite=Strict on the refresh token cookie --- no additional CSRF token required

  • Content-Security-Policy: default-src 'self'; connect-src restricted to the known API origin; no inline scripts

  • JSON import validation: files are parsed and schema-checked entirely client-side before any data is sent to the backend

  • Image uploads: MIME type checked against an allowlist (image/png, image/jpeg, image/webp) and size capped at 2 MB client-side; backend re-validates before issuing the presigned URL

11.8 Sync Between Web App and Symbiote

Both clients write to the same REST API so data is always consistent at rest. For changes made while a session is active there are two scenarios:

  • HP change mid-combat (web app to Symbiote): PATCH /characters/:id updates the backend; backend pushes a WebSocket event to the group room; Symbiote receives and re-renders. Typical latency under 500 ms.

  • Backstory edit between sessions (web app): no live sync needed. The Symbiote fetches the latest sheet_data on its next startup.

The web app does not implement TS.sync --- that channel is internal to TaleSpire. All cross-client communication routes through the backend WebSocket room keyed to the group ID.

13. Content Service

The Content Service is a standalone Rust + Axum microservice with its own PostgreSQL database, deployed independently of the Campaign Service. It is the authoritative source for all ruleset reference data: monsters, items, spells, talents, rules, rulebooks, and inter-content dependencies. The Campaign Service calls the Content Service at runtime to power searchable pickers, derived stat calculations, and rule validation in the character editor.

13.1 Why a Separate Service


Concern Rationale for Separation


Data ownership Ruleset content is shared global data; character/group data is per-user. Different access patterns, different retention rules.

Copyright sensitivity Rulebook text and official stat blocks may carry licensing restrictions. Isolating them in a dedicated service makes it easier to apply different access controls or redact content per jurisdiction.

Scaling Content reads are high-volume and cache-friendly. Campaign writes are low-volume and transactional. Independent scaling avoids coupling.

Deployment cadence New ruleset content ships on its own schedule, independently of auth or character sheet fixes.

Rust workspace Each service is a separate crate in a Cargo workspace, sharing common types (content_types, campaign_types) via internal library crates.

13.2 Content Data Model

All content belongs to a ruleset and is versioned. The ruleset_id foreign key ties every content record to a specific game system. The schema is intentionally flat and generic at the top level, with a JSONB data blob for system-specific fields --- the same pattern as sheet_data in the Campaign Service.

rulesets (content DB)


Field Type Required Description


id VARCHAR(100) Yes Slug, e.g. (PK) 'dsa5e', 'dnd5e'

name VARCHAR(200) Yes Display name, e.g. 'Das Schwarze Auge 5e'

version VARCHAR(50) Yes Ruleset schema version, semver

maintainer_notes TEXT No Shown in the editor UI

created_at TIMESTAMPTZ Yes

content_entries


Field Type Required Description


id UUID (PK) Yes

ruleset_id VARCHAR(100) (FK) Yes Links to rulesets.id

entry_type VARCHAR(50) Yes One of: spell, talent, item, monster, rule, rulebook, condition, trait, special_ability

slug VARCHAR(200) Yes URL-safe unique identifier within a ruleset+type, e.g. 'fireball'

name VARCHAR(300) Yes Display name

source_book VARCHAR(200) No Publication reference, e.g. 'Core Rulebook p.142'

data JSONB Yes Type-specific fields; validated against entry_type schema on write

status ENUM(draft,review,approved,deprecated) Yes Content moderation state

created_by UUID (FK → users) Yes Author of the submission

approved_by UUID (FK → users) No NULL until approved

created_at TIMESTAMPTZ Yes

updated_at TIMESTAMPTZ Yes

content_dependencies


Field Type Required Description


from_entry_id UUID (FK) Yes Composite PK with to_entry_id + dep_type

to_entry_id UUID (FK) Yes The required entry

dep_type VARCHAR(50) Yes One of: requires, excludes, grants, replaces, modifies

condition JSONB No Optional predicate, e.g. { min_level: 3 }

ruleset_maintainers


Field Type Required Description


ruleset_id VARCHAR(100) Yes Composite PK (FK)

user_id UUID (FK → Yes Composite PK users)

can_approve BOOLEAN Yes true = can move entries to approved status

granted_by UUID (FK → Yes Admin who assigned users) this role

granted_at TIMESTAMPTZ Yes

The content_dependencies table captures the dependency graph between entries. For DSA5e this covers cases like: a special ability requires a minimum talent level, a spell has a prerequisite spell, or two traits are mutually exclusive. The Campaign Service walks this graph during character validation.

13.3 entry_type JSONB Schemas

Each entry_type has a JSON Schema registered in the Content Service. Writes are validated server-side against the schema for the declared type. This means the Campaign Service and editor UI can always trust that data blobs are well-formed. Example schemas for DSA5e:

talent

{ name, category, check_attributes: [attr, attr, attr],

encumbrance: bool, improvement_cost: 'A'|'B'|'C'|'D' }

spell

{ name, tradition, property, casting_time, cost: { asp: int },

range, duration, target, effect, enhancement_levels: [...] }

monster

{ name, size, lep, ini, at, pa, rs, mr, gs,

attacks: [{ name, dice, damage_type }], loot: [...] }

item

{ name, item_type, weight, price, availability,

combat_stats?: { at_mod, pa_mod, damage, reach } }

13.4 Access Control & Moderation

Content goes through a three-stage lifecycle: draft → review → approved. Only approved entries are returned by the public read API that the Campaign Service calls. This keeps unreviewed community submissions from reaching character sheets.


Role Permissions


Any registered user Submit new entries (status = draft); edit own draft entries; view all approved entries

Ruleset maintainer All of the above, plus: move entries to review or approved; edit any entry in their ruleset; deprecate entries

Admin All of the above across all rulesets; grant/revoke maintainer roles; delete entries; manage rulesets

  • A maintainer can_approve flag distinguishes senior maintainers (who can publish) from junior contributors (who can submit to review but not approve)

  • Approval of an entry that has unresolved dependency entries is blocked --- all deps must be approved first

  • Deprecation is soft: deprecated entries remain on existing character sheets but are hidden from pickers in the editor

13.5 Content Service API

Base URL: https://content.campaign-manager.example.com/v1

Public Read Endpoints (called by Campaign Service and web editors)


Method + Path Description


GET /rulesets List all rulesets with entry counts

GET /rulesets/:id/entries Search entries; filter by entry_type, text query, status. Returns approved only unless caller is maintainer/admin.

GET /entries/:id Full entry including data blob

GET /entries/:id/dependencies Full resolved dependency graph (recursive)

POST Given a partial sheet_data, check all dep /entries/:id/validate-sheet constraints. Returns list of violations.

Authenticated Write Endpoints (users, maintainers, admins)


Method + Path Description


POST /rulesets/:id/entries Submit a new entry (status = draft)

PATCH /entries/:id Edit an entry (own drafts, or maintainer/admin for any)

POST /entries/:id/status Transition status (maintainer: draft→review→approved; admin: any)

DELETE /entries/:id Hard delete (admin only; soft deprecation preferred)

POST /entries/:id/dependencies Add a dependency edge

DELETE Remove a dependency edge /entries/:id/dependencies/:depId

POST /rulesets/:id/maintainers Grant maintainer role (admin only)

13.6 Service-to-Service Communication

The Campaign Service calls the Content Service over internal HTTP (cluster-internal DNS in Kubernetes). Calls use a shared service account JWT, not a user token. There are three integration points:

  • Picker search: when a user opens a talent or spell picker in the character editor, the Campaign Service proxies a GET /rulesets/:id/entries request with the user's search term and returns the results to the Svelte frontend. The frontend never calls the Content Service directly.

  • Derived stat calculation: the campaign service fetches the relevant entries (weapon, talent, attributes) and passes them to the ruleset plugin's calculate() function, which returns updated derived values. The result is written back to sheet_data.

  • Validation: on every character save (PUT /characters/:id), the Campaign Service calls POST /entries/:id/validate-sheet for each selected ability or item. Any violations are returned as a structured error list before the save is committed.

Response caching: approved content is immutable once published. The Campaign Service caches Content Service responses in-process (a simple DashMap<entry_id, CachedEntry> in Rust) with a 5-minute TTL, reducing cross-service latency for the most common picker lookups.

13.7 Content Editor Web App

A second web application (content.campaign-manager.example.com) provides a full editing UI for the content database, separate from the character management web app. It is a Svelte + Vite static build deployed to the same CDN.

Editor Pages


Route Access Description


/ All Ruleset browser; search across all approved content

/:rulesetId All Ruleset overview: entry counts by type, maintainer list, recent activity

/:rulesetId/entries All Filterable table of all entries; status badges; click to view

/:rulesetId/entries/new Auth user Entry submission form; type selector renders appropriate JSONB fields

/:rulesetId/entries/:id All Full entry view with dependency graph (approved) / visualisation Auth (draft)

/:rulesetId/entries/:id/edit Owner / Full entry editor with field validation Maintainer / against entry_type schema Admin

/review-queue Maintainer / All entries in draft or review status Admin awaiting action

/admin/maintainers Admin Grant and revoke maintainer roles per ruleset

Dependency Graph View

The entry detail page renders the dependency graph as an interactive SVG using a force-directed layout. Nodes are colour-coded by entry_type; edges are labelled with dep_type (requires, excludes, grants, replaces, modifies). Clicking any node navigates to that entry. This gives maintainers an immediate visual overview of complex prerequisite chains --- especially important for DSA5e's special ability trees.

Entry Form

The submission and edit forms are data-driven: selecting an entry_type loads the JSON Schema for that type and renders the appropriate fields dynamically. Required fields are marked; enum fields render as dropdowns; nested arrays (e.g. enhancement_levels on a spell) render as repeatable field groups. Client-side schema validation gives inline errors before the API is called.

13.8 Storage --- No PVC

The Content Service, like the Campaign Service, runs with no Persistent Volume Claims in Kubernetes. All persistence is in the Content Service's dedicated PostgreSQL instance. There is no file storage in the Content Service --- rulebook PDFs or source images are out of scope; the service stores only structured data.

13.9 Shared Auth

Both services validate JWTs issued by the same auth system (shared public key). The user identity (user_id, email) and a roles claim are embedded in the token. The roles claim includes an array of ruleset maintainer grants, e.g.:

{ "sub": "uuid", "roles": ["maintainer:dsa5e", "admin"] }

This means the Content Service does not need to call the Campaign Service to check permissions --- it reads the token directly. The Campaign Service issues tokens; both services consume them.

14. Deployment & Repository Layout

The project is a monorepo with two top-level workspaces: a Cargo workspace for the two Rust backend services, and a pnpm workspace for the three Svelte frontend surfaces. The Symbiote is not a Docker image --- it is a static file package distributed via mod.io. The two web frontends and the two backend services each produce a Docker image, for four images total.

14.1 Monorepo Structure

campaign-manager/

├── backend/ # Cargo workspace

│ ├── Cargo.toml # workspace root

│ ├── common/ # shared library crate

│ │ └── src/ # JWT types, error types, content_types, campaign_types

│ ├── campaign-service/ # binary crate

│ │ ├── Cargo.toml

│ │ ├── Dockerfile

│ │ └── src/

│ └── content-service/ # binary crate

│ ├── Cargo.toml

│ ├── Dockerfile

│ └── src/

├── frontend/ # pnpm workspace

│ ├── package.json # workspace root

│ ├── packages/

│ │ ├── ruleset-core/ # plugin interface + DSA5e plugin

│ │ ├── ui-components/ # shared Svelte components

│ │ └── api-client/ # shared fetch wrapper (auto-refresh)

│ └── apps/

│ ├── symbiote/ # TaleSpire Symbiote --- NOT a Docker image

│ │ ├── manifest.json

│ │ └── src/

│ ├── campaign-web/ # Docker image

│ │ ├── Dockerfile

│ │ └── src/

│ └── content-editor/ # Docker image

│ ├── Dockerfile

│ └── src/

├── k8s/ # Kubernetes manifests

│ ├── campaign-service/

│ ├── content-service/

│ ├── campaign-web/

│ ├── content-editor/

│ └── ingress/

└── .github/workflows/ # CI/CD pipelines

14.2 Docker Images

All four Docker images use multi-stage builds. The Rust images compile in a builder stage and copy only the final static binary into a minimal runtime image, keeping production images small and attack-surface minimal.

14.2.1 campaign-service and content-service (Rust)

# backend/campaign-service/Dockerfile

FROM rust:1.77-slim AS builder

WORKDIR /build

COPY . .

RUN cargo build --release -p campaign-service

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*

COPY --from=builder /build/target/release/campaign-service /usr/local/bin/

EXPOSE 8080

CMD ["campaign-service"]

The content-service Dockerfile is identical with the binary name swapped. Final images land under 30 MB. The Cargo workspace COPY means both Dockerfiles copy the full workspace, so the common crate is always available --- Docker layer caching on the dependency compile step keeps rebuild times fast in CI.

14.2.2 campaign-web and content-editor (Svelte)

# frontend/apps/campaign-web/Dockerfile

FROM node:20-slim AS builder

RUN npm install -g pnpm

WORKDIR /build

COPY frontend/ .

RUN pnpm install --frozen-lockfile

RUN pnpm --filter campaign-web build

FROM nginx:alpine

COPY --from=builder /build/apps/campaign-web/dist /usr/share/nginx/html

COPY frontend/apps/campaign-web/nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

The nginx.conf sets try_files $uri $uri/ /index.html for SPA routing and adds cache headers: immutable long-cache for hashed assets, no-store for index.html. The content-editor Dockerfile is identical with the app name swapped.

14.2.3 Image Summary


Image Base Est. Serves Size


campaign-service debian:bookworm-slim ~25 MB Auth, groups, characters, dice history

content-service debian:bookworm-slim ~25 MB Rulesets, entries, dependencies, moderation

campaign-web nginx:alpine ~15 MB Campaign management Svelte SPA

content-editor nginx:alpine ~12 MB Content editor Svelte SPA

symbiote N/A ~2 MB zip Distributed via mod.io --- not a container

14.3 Kubernetes Manifests

Each service gets its own directory under k8s/. The cluster has no PVCs --- all state lives in managed PostgreSQL instances (one per service) and S3-compatible object storage. Config and secrets are injected via ConfigMaps and Secrets respectively; nothing sensitive is baked into an image.

14.3.1 campaign-service Deployment

# k8s/campaign-service/deployment.yaml

apiVersion: apps/v1

kind: Deployment

metadata:

name: campaign-service

spec:

replicas: 2

template:

spec:

containers:

- name: campaign-service

image: ghcr.io/yourorg/campaign-service:latest

ports:

- containerPort: 8080

envFrom:

- configMapRef:

name: campaign-config

- secretRef:

name: campaign-secrets

readinessProbe:

httpGet: { path: /health, port: 8080 }

resources:

requests: { cpu: 100m, memory: 64Mi }

limits: { cpu: 500m, memory: 256Mi }

The content-service Deployment is structured identically. Both services expose a /health endpoint that checks database connectivity.

14.3.2 ConfigMaps and Secrets


Key Kind Used by


DATABASE_URL Secret campaign-service --- Postgres connection string

CONTENT_DATABASE_URL Secret content-service --- Postgres connection string

JWT_PRIVATE_KEY Secret campaign-service --- issues tokens

JWT_PUBLIC_KEY Secret Both services --- verifies tokens

S3_BUCKET / Secret campaign-service --- portrait uploads S3_ENDPOINT / S3_KEY

CONTENT_SERVICE_URL ConfigMap campaign-service --- internal URL for content calls

CORS_ORIGINS ConfigMap Both services --- allowlisted web app origins

VITE_API_BASE_URL ConfigMap campaign-web, content-editor --- injected at build time

14.3.3 Services and Ingress

# Internal cluster services (ClusterIP --- not externally reachable)

campaign-service:8080 → campaign pods

content-service:8080 → content pods

# Ingress (TLS terminated at ingress controller)

api.campaign-manager.example.com → campaign-service

content.campaign-manager.example.com → content-service (public read)

+ content-editor (nginx, static)

campaign-manager.example.com → campaign-web (nginx, static)

The campaign-web and content-editor nginx pods are fronted by the same ingress controller. Static asset paths (/assets/*) have a separate ingress rule that sets long cache headers at the ingress level in addition to what nginx returns.

14.4 Database Migrations

Each Rust service owns its own migrations using sqlx-migrate. Migrations run automatically at startup before the HTTP server binds, guaranteeing the schema is always in sync with the binary. Rolling back is done by deploying the previous image version; down-migrations are written for every change.

# Embedded in each binary --- no separate init container required

sqlx::migrate!('./migrations').run(&pool).await?;

14.5 CI/CD Pipelines

GitHub Actions runs three pipelines on pull request and on merge to main.

backend.yml --- Rust workspace

  • cargo fmt --check and cargo clippy --deny warnings

  • cargo test --workspace (unit + integration tests against a test Postgres spun up via a service container)

  • cargo build --release for both binaries

  • docker build + push to GHCR for campaign-service and content-service on merge to main

  • kubectl rollout restart deployment/campaign-service and content-service in the target cluster

frontend.yml --- pnpm workspace

  • pnpm install --frozen-lockfile

  • pnpm --filter ruleset-core test and pnpm --filter api-client test

  • pnpm --filter campaign-web build and pnpm --filter content-editor build

  • docker build + push to GHCR for campaign-web and content-editor on merge to main

  • kubectl rollout restart deployment/campaign-web and content-editor on merge to main

symbiote.yml --- mod.io release

  • pnpm --filter symbiote build (outputs to apps/symbiote/dist/)

  • zip -r symbiote-v$VERSION.zip manifest.json dist/

  • Upload to mod.io via the mod.io REST API using a repository secret MOD_IO_API_KEY

  • Triggered only on a version tag push (v*.*.*) --- not on every merge to main

  • GitHub Release created automatically with the zip attached as an asset

14.6 Local Development

A docker-compose.yml at the repo root spins up the two Postgres databases and a MinIO instance (S3-compatible) for local development. The Rust services and Svelte apps run natively on the host, hot-reloading against the containerised datastores.

services:

campaign-db:

image: postgres:16-alpine

environment: { POSTGRES_DB: campaign, POSTGRES_PASSWORD: dev }

ports: ['5432:5432']

content-db:

image: postgres:16-alpine

environment: { POSTGRES_DB: content, POSTGRES_PASSWORD: dev }

ports: ['5433:5432']

minio:

image: minio/minio

command: server /data

ports: ['9000:9000', '9001:9001']

The Symbiote is developed by pointing TaleSpire at the local Symbiote directory (apps/symbiote/) and running pnpm --filter symbiote dev, which writes changes into that directory on every save, triggering TaleSpire's live-reload.

15. Caching Strategy

Caching is handled by two isolated Valkey instances --- one per backend service --- plus HTTP cache headers for static frontend assets. Valkey is a fully open-source Redis-compatible store; the Rust services connect using the redis-rs crate. Each service also maintains a small in-process L1 cache (moka) in front of Valkey for the hottest reads, giving a two-tier cache hierarchy with no cross-service coupling.

15.1 Two-Tier Cache Architecture


Tier Technology Scope Typical TTL


L1 moka (in-process) Single pod, 5--30 seconds per-request dedup

L2 Valkey (shared All pods of one Minutes to hours, or across pods) service until invalidated

L3 PostgreSQL Source of truth N/A

On a cache read: L1 is checked first. On L1 miss, L2 (Valkey) is checked. On L2 miss, the database is queried and the result is written back to both L1 and L2. On a cache write (active invalidation): the database is updated first, then the Valkey key is deleted, then the L1 entry is evicted. The next read from any pod repopulates both tiers from the fresh database value.

15.2 Campaign Service Valkey

The campaign-service Valkey instance handles three concerns: refresh token storage, character sheet caching, and JWT public-key caching for inbound content-service tokens.

15.2.1 Refresh Token Store

Refresh tokens are stored in Valkey with a TTL matching their validity window (30 days). On logout or token rotation, the old token is deleted immediately. This is the one case where Valkey is the primary store rather than a cache --- the token record does not exist in PostgreSQL.

# Key pattern

refresh_token:{token_hash} → { user_id, issued_at } TTL: 30d

# On login: SET refresh_token:{hash} {payload} EX 2592000

# On refresh: DEL refresh_token:{old_hash}

# SET refresh_token:{new_hash} {payload} EX 2592000

# On logout: DEL refresh_token:{hash}

Because refresh tokens are security-critical, this Valkey instance is configured with appendonly yes (AOF persistence) so tokens survive a pod restart. This is the one exception to the no-PVC rule --- the campaign-service Valkey gets a small PVC (1 Gi) for AOF data.

15.2.2 Character Sheet Cache

Character sheets are read frequently during sessions (Symbiote polls on reconnect; web app loads on navigation) but written infrequently. Active invalidation on write ensures any pod always serves the current sheet.

# Key pattern

character:{character_id} → JSON sheet payload TTL: 1h (safety fallback)

# On GET /characters/🆔 check L1 → check Valkey → query DB → backfill

# On PUT/PATCH /characters/🆔 write DB → DEL character:{id} → evict L1

The 1-hour TTL is a safety fallback only --- active invalidation means the key is deleted on every write, so stale reads should not occur in practice. The TTL catches any edge case where invalidation fails (e.g. a pod crash mid-write).

15.2.3 Group Membership Cache

Permission checks on group endpoints (is this user a member? are they the DM?) are performed on nearly every request. Caching the membership record avoids a database round-trip on each check.

# Key pattern

group_member:{group_id}:{user_id} → { role } TTL: 10m

# Invalidated on: member add, member remove, DM transfer

15.3 Content Service Valkey

The content-service Valkey instance caches content entries, dependency graphs, and ruleset metadata. Content is read extremely frequently (every picker interaction, every character save validation) but changes only when a maintainer approves or edits an entry. Active invalidation on write makes this straightforward.

15.3.1 Entry Cache

# Key pattern

entry:{entry_id} → full entry JSON TTL: 24h

entry_list:{ruleset_id}:{type} → list of entry IDs TTL: 10m

entry_search:{ruleset_id}:{hash_of_query} → result list TTL: 5m

Individual entry records are cached with a long TTL because active invalidation handles freshness. Search result lists use a shorter TTL since they are harder to invalidate precisely --- a new approval could change any search result.

15.3.2 Active Invalidation on Approval

When a maintainer transitions an entry to approved, or edits an existing approved entry, the content-service invalidates aggressively:

// In Rust --- on entry status change or edit

valkey.del(format!("entry:{}", entry_id)).await?;

valkey.del(format!("entry_list:{}:{}", ruleset_id, entry_type)).await?;

// Flush all search keys for this ruleset (pattern delete)

let keys: Vec<String> = valkey.keys(

format!("entry_search:{}:*", ruleset_id)

).await?;

if !keys.is_empty() {

valkey.del(keys).await?;

}

Pattern deletes (KEYS) are used here because the search key space is small and writes are infrequent. For a large-scale deployment this could be replaced with a dedicated search invalidation topic, but for v1 the pattern scan is acceptable.

15.3.3 Dependency Graph Cache

Dependency graphs are the most expensive reads --- resolving a deep prerequisite chain requires recursive joins. The resolved graph for each entry is cached as a single serialised JSON blob.

# Key pattern

dep_graph:{entry_id} → resolved graph JSON TTL: 24h

# Invalidated when: the entry itself changes, OR any entry in its

# dependency graph changes. On any dep edge add/remove:

# DEL dep_graph:{from_entry_id}

# DEL dep_graph:{to_entry_id} (graph may be traversed from either end)

15.3.4 Ruleset Metadata Cache

ruleset:{ruleset_id} → ruleset metadata JSON TTL: 1h

ruleset_list → list of all rulesets TTL: 1h

Ruleset records change rarely (new rulesets are infrequent). A 1-hour TTL with active invalidation on any ruleset write is sufficient.

15.4 Key Naming Conventions

Both services follow a consistent key naming scheme to make debugging and monitoring straightforward:


Pattern Service Notes


refresh_token:{hash} Campaign Security-critical; AOF persisted

character:{id} Campaign Active invalidation on every write

group_member:{group_id}:{user_id} Campaign Short TTL; invalidated on membership change

entry:{id} Content Active invalidation on approval/edit

entry_list:{ruleset_id}:{type} Content Active invalidation on any entry change in ruleset

entry_search:{ruleset_id}:{query_hash} Content Short TTL; pattern-deleted on ruleset changes

dep_graph:{id} Content Active invalidation on dep edge changes

ruleset:{id} / ruleset_list Content TTL-based; active invalidation on ruleset write

15.5 Kubernetes Deployment

Each Valkey instance runs as a single-replica StatefulSet with a PVC. The campaign-service Valkey requires AOF persistence for refresh tokens; the content-service Valkey uses RDB snapshots only (content is fully recoverable from the database on a cold start).

# k8s/campaign-valkey/statefulset.yaml (abbreviated)

kind: StatefulSet

metadata:

name: campaign-valkey

spec:

replicas: 1

template:

spec:

containers:

- name: valkey

image: valkey/valkey:7-alpine

args: ["--appendonly", "yes"] # AOF for campaign-valkey

volumeMounts:

- name: data

mountPath: /data

volumeClaimTemplates:

- metadata:

name: data

spec:

accessModes: [ReadWriteOnce]

resources:

requests:

storage: 1Gi

The content-service Valkey StatefulSet is identical except --appendonly yes is omitted. Its PVC is 512 Mi --- content cache is warm within minutes of a cold start from the database.


Instance Persistence PVC AOF RDB Used by


campaign-valkey Required (tokens) 1 Gi Yes Yes campaign-service

content-valkey Warm-up only 512 Mi No Yes content-service

15.6 Static Asset Caching (Frontend)

The two nginx containers (campaign-web, content-editor) serve Svelte build output where all JS/CSS filenames include a content hash (e.g. main.a3f92c.js). This enables aggressive CDN caching with safe long-lived headers.


Path pattern Cache-Control Rationale


/assets/* max-age=31536000, Content-hashed filenames; safe to (hashed) immutable cache for 1 year

/index.html no-store Must always be fresh so new asset hashes are discovered

/manifest.json no-store TaleSpire re-reads this to detect (Symbiote) updates

API responses no-store Dynamic data; never cached at CDN (/v1/*) or browser

A CDN (e.g. Cloudflare) can be placed in front of both nginx deployments. Because index.html is no-store, a new deployment is picked up by all clients on their next page load without a cache purge step.

15.7 Cache Miss Behaviour & Resilience

Both services are designed to function correctly with Valkey completely unavailable --- degraded performance but no outage. Every cache read is wrapped in a fallback to the database:

// Rust pseudocode --- applies to both services

async fn get_entry(id: Uuid, valkey: &Valkey, db: &PgPool) -> Result<Entry> {

// L1: in-process moka cache

if let Some(entry) = L1_CACHE.get(&id) { return Ok(entry); }

// L2: Valkey

match valkey.get::<Entry>(&format!("entry:{id}")).await {

Ok(Some(entry)) => { L1_CACHE.insert(id, entry.clone()); return Ok(entry); }

Ok(None) => {} // cache miss --- fall through

Err(_) => {} // Valkey unavailable --- fall through

}

// L3: database

let entry = db.query_entry(id).await?;

let _ = valkey.set_ex(&format!("entry:{id}"), &entry, 86400).await;

L1_CACHE.insert(id, entry.clone());

Ok(entry)

}

Valkey errors are logged as warnings but never propagated as request errors. A Valkey outage increases database load but does not cause user-facing failures.

16. Implementation Plan

This plan is optimised for a solo developer targeting the fastest path to something playable at the table. Each phase ends with a concrete, usable milestone. Later phases are intentionally deferred until the earlier ones are proven --- complexity is introduced only when it is needed.

Assumed starting point: Kubernetes cluster available; Rust and Svelte are new. The plan accounts for a learning curve on both.

16.1 Phase Overview


# Phase Milestone Defers


0 Foundations Monorepo builds locally; Everything else CI runs; DB migrations
apply

1 Vertical Slice One player can log in, Groups, multi-user, see a DSA5e sheet, roll web app, content dice in TaleSpire service, Valkey

2 Groups & DM creates group, invites Web app, content Sessions players, all see each service, Valkey, other's sheets campaign journal

3 Campaign Web App Characters created and Content service, edited in browser; JSON Valkey, pickers, import works auto-calc

4 Caching Valkey added to both Content service services; character
sheets and tokens cached

5 Content Service DSA5e entries in the Community content DB; pickers and contributions, validation live in editor maintainer roles

6 Polish & Campaign journal, offline --- Community mode, mod.io publish,
community content
submissions

16.2 Phase 0 --- Foundations

Goal: a working monorepo skeleton where both Rust services compile, both Svelte apps build, CI passes, and database migrations apply cleanly. No features yet --- this phase is about eliminating all infrastructure friction before writing any product code.

Tasks

  • Initialise Cargo workspace with campaign-service, content-service, and common crates. Add sqlx, axum, tokio, and serde as dependencies. Confirm cargo build --release works.

  • Initialise pnpm workspace with packages/ruleset-core, packages/ui-components, packages/api-client and apps/symbiote, apps/campaign-web, apps/content-editor. Confirm pnpm build runs across all.

  • Write the docker-compose.yml with campaign-db (port 5432), content-db (port 5433), and MinIO (port 9000). Confirm docker compose up starts cleanly.

  • Write the first sqlx migration for campaign-service (users table only). Confirm sqlx migrate run applies it against the local DB.

  • Set up GitHub Actions: backend.yml (fmt + clippy + test), frontend.yml (build check). Confirm both pass on an empty push.

  • Write Dockerfiles for all four images. Confirm docker build succeeds locally for each.

  • Deploy a placeholder health-check endpoint (GET /health → 200) for each Rust service to the k8s cluster. Confirm kubectl get pods shows them running.

Acceptance Criteria

  • cargo test --workspace passes with zero warnings

  • pnpm build succeeds for all three apps

  • Both services reachable at their k8s ingress URLs returning 200 on /health

  • CI pipeline is green on main

Rust Learning Focus for This Phase

  • Understand the Cargo workspace model --- crates, dependencies, feature flags

  • Get comfortable with axum's Router and basic handler signatures

  • Understand sqlx's compile-time query checking and how to run migrations

16.3 Phase 1 --- Vertical Slice

Goal: one player can register, log in, open the Symbiote in TaleSpire, see their DSA5e character sheet, and click a button to put dice in their hand. This is the core loop. Everything else in the project exists to support and extend this.

Backend (campaign-service)

  • Add users table migration. Implement POST /auth/register and POST /auth/login returning a JWT pair. Store refresh tokens in Postgres for now (Valkey comes in Phase 4).

  • Add characters table migration with sheet_data JSONB. Implement GET/POST/PUT /characters endpoints. No group logic yet --- characters are owned by a user and that is enough.

  • Implement POST /auth/refresh and POST /auth/logout (revoke refresh token row in DB).

  • Write integration tests for the full auth flow using a test database.

Ruleset Core (packages/ruleset-core)

  • Define the TypeScript Ruleset interface: id, name, defaultSheet(), validate(), getStatBlocks(), getDiceActions(), renderSheet().

  • Implement the dsa5e plugin: attributes (MU, KL, IN, CH, FF, GE, KO, KK), derived values (LeP, AsP, KaP), talents, and a minimal set of combat dice actions. Keep it small --- a playable sheet, not a complete implementation.

  • Write unit tests for the dsa5e plugin's validate() and getDiceActions() against a sample sheet fixture.

Symbiote (apps/symbiote)

  • Wire up the TS API hasInitialized event. On init, check TS.storage for tokens. If none, show a login form.

  • Implement login: POST /auth/login via the api-client package, store tokens in TS.storage, navigate to the sheet view.

  • Implement the character sheet view: fetch GET /characters (filter to the user's first character), pass sheet_data to the dsa5e plugin's renderSheet(), display the result.

  • Implement dice action buttons: each DiceAction from getDiceActions() renders as a button that calls TS.dice.putDiceInHand(action.diceString).

  • Register a TS dice result listener and log rolls to the console (dice history UI comes in Phase 2).

  • Test the full loop manually in TaleSpire: register → login → see sheet → click roll button → dice appear in hand.

Acceptance Criteria

  • New user can register and log in via the Symbiote

  • DSA5e character sheet renders with correct attribute and derived value fields

  • At least three dice action buttons work (e.g. attribute check 3d6, melee attack, dodge)

  • Clicking a dice button puts the correct dice string in the TaleSpire dice hand

  • Token refresh works silently --- a 15-minute access token expiry does not log the user out

Svelte Learning Focus for This Phase

  • Understand Svelte's reactivity model ($: declarations, stores)

  • Get comfortable with component props and event dispatch

  • Understand how Vite handles imports across the pnpm workspace packages

16.4 Phase 2 --- Groups & Sessions

Goal: a DM can create a group, invite players, and during a session everyone can see each other's sheets and live roll history in the Symbiote.

Backend (campaign-service)

  • Add groups, group_members, and group_characters migrations.

  • Implement full groups API: POST /groups, GET /groups, GET /groups/:id, PATCH /groups/:id, DELETE /groups/:id.

  • Implement invite flow: POST /groups/:id/invite (generates a token), POST /groups/:id/join/:token.

  • Implement GET/POST/DELETE /groups/:id/characters for character assignment.

  • Implement POST /groups/:id/rolls and GET /groups/:id/rolls for dice history.

  • Add WebSocket support to axum (axum::extract::ws). Implement a group room: authenticate via query param JWT, join room by group_id, broadcast roll events to all connected clients in the room.

Symbiote

  • Add a group selection screen after login: list groups, allow creating or joining one.

  • On entering a group as DM: show all members' character sheets in a sidebar. On entering as player: show own sheet plus a compact view of party members' HP.

  • Connect to the WebSocket roll room on group entry. Render a live roll history panel (character name, dice string, result, timestamp).

  • On dice result event from TS API, POST to /groups/:id/rolls and emit via WebSocket so all clients see it in real time.

  • Handle board switch events: suppress API calls while TS signals a board transition; reconnect WebSocket after rejoining.

Acceptance Criteria

  • DM creates a group and shares an invite link; player joins successfully

  • Both clients see the same roll history panel update live when either player rolls

  • DM can see all character sheets; player sees own sheet plus party HP bars

  • Group survives a board switch without losing connection or state

16.5 Phase 3 --- Campaign Web App

Goal: players can manage their characters and groups from a browser before the session. JSON import lets players bring characters from other tools.

campaign-web (apps/campaign-web)

  • Set up SvelteKit with file-based routing. Configure the HttpOnly cookie auth flow (login page, silent refresh on load, protected route guard).

  • Build the group dashboard (/groups): card grid, create group button, invite flow.

  • Build the character library (/characters): list all owned characters, create new, delete.

  • Build the character editor (/characters/:id): full DSA5e sheet form rendered by the dsa5e plugin's renderSheet(), auto-save every 30 seconds, optimistic UI on field changes.

  • Build the JSON import flow: file picker, detect .cmchar vs generic JSON, render field mapper for generic JSON, run validate() before saving, show field-level errors.

  • Build the character export: GET /characters/:id → download as .cmchar file.

  • Add portrait upload: drag-and-drop to a file input, validate MIME + size client-side, request a presigned URL from the backend, upload directly to MinIO/S3.

Backend additions

  • Add presigned URL endpoint: POST /characters/:id/portrait-upload-url → returns a signed S3 PUT URL valid for 5 minutes.

  • Add GET /characters/:id/history endpoint returning last 10 sheet_data versions (store versions in a character_history table --- add the migration now).

  • Configure CORS to allow the campaign-web origin in addition to localhost:8080.

Acceptance Criteria

  • Player registers on the web app, creates a DSA5e character, and it appears immediately in the Symbiote

  • .cmchar export from the web app imports cleanly back into a fresh account

  • Generic JSON import with a custom field mapping creates a valid character

  • Portrait upload appears on the character sheet in the Symbiote

16.6 Phase 4 --- Caching

Goal: add Valkey to both services, move refresh tokens out of Postgres, and cache character sheets and content entries. The system should handle a session with no database reads for hot paths.

Tasks

  • Deploy campaign-valkey StatefulSet (AOF, 1 Gi PVC) and content-valkey StatefulSet (RDB only, 512 Mi PVC) to the k8s cluster.

  • Add redis-rs and moka as dependencies to both Rust crates.

  • campaign-service: migrate refresh token storage from Postgres to Valkey. Add the L1/L2 read-through wrapper for GET /characters/:id and group membership checks. Add active invalidation DEL calls to PUT/PATCH /characters/:id and all group membership mutation endpoints.

  • content-service: add L1/L2 read-through for GET /rulesets/:id/entries and GET /entries/:id. Add active invalidation on all entry write endpoints.

  • Write a load test (using k6 or oha) against GET /characters/:id with a warm cache. Target: p99 < 10 ms.

  • Confirm Valkey-down behaviour: stop the Valkey pod, verify requests still succeed (slower, DB fallback), restart Valkey, verify cache warms back up.

Acceptance Criteria

  • p99 latency on cached character reads under 10 ms

  • Valkey pod restart causes no user-facing errors

  • Refresh token revocation (logout) takes effect immediately on all pods

16.7 Phase 5 --- Content Service

Goal: DSA5e reference data lives in the content DB. The character editor uses searchable pickers for talents, spells, and items. Saves are validated against the dependency graph.

Tasks

  • Set up the content-service binary with its own Postgres DB and Valkey. Write the baseline migrations: rulesets, content_entries, content_dependencies, ruleset_maintainers.

  • Seed the DSA5e ruleset with a complete set of talents, a representative set of spells and special abilities, and common items. Write a seed script that reads from a JSON fixture file so it is repeatable.

  • Implement the full Content Service API (§13.5): public read endpoints, authenticated write endpoints, status transition endpoint.

  • Implement POST /entries/:id/validate-sheet --- walk the dependency graph for all entry IDs referenced in the sheet, return a structured list of violations.

  • campaign-service: add the internal service-to-service HTTP client (reqwest). Add the in-process DashMap cache for content responses. Wire picker search and pre-save validation into PUT /characters/:id.

  • campaign-web: replace plain text fields in the character editor with searchable pickers for talents, spells, and items. Render validation errors returned by the backend.

  • Deploy content-service to k8s. Configure the cluster-internal DNS so campaign-service can reach content-service:8080 without going through the public ingress.

Acceptance Criteria

  • Talent picker in the character editor searches and returns correct DSA5e results

  • Selecting a special ability with an unmet prerequisite shows a validation error

  • Content service is not reachable from outside the cluster (ClusterIP only for write endpoints)

16.8 Phase 6 --- Polish & Community

Goal: the project is ready for public mod.io release and community content contributions.

Tasks

  • Campaign journal: add journal_entries table migration to campaign-service. Build the journal UI in both the Symbiote (read + write during session) and campaign-web (full editor between sessions).

  • Offline mode: implement TS.storage sheet caching in the Symbiote. Detect network failures and show a read-only cached sheet with an offline banner. Queue writes and flush on reconnect.

  • Content editor web app (apps/content-editor): build the full editing UI --- entry browser, submission form, review queue, dependency graph visualisation (SVG force-directed layout using d3 or similar).

  • Community contributions: open up the content service write endpoints to registered users (draft submissions). Recruit DSA5e maintainers.

  • mod.io release: package the Symbiote, write a good description and screenshots, upload via the mod.io API. Set up the symbiote.yml CI pipeline for automated releases on version tags.

  • Ruleset marketplace groundwork: add a ruleset submission flow for community-created rulesets. Define the review process.

Acceptance Criteria

  • Symbiote is listed and installable from the TaleSpire in-game mod browser

  • Offline sheet is readable after disabling the network mid-session

  • A community member can submit a DSA5e talent entry and a maintainer can approve it through the content editor UI

16.9 Recommended Learning Path

As a solo developer new to Rust and Svelte, the following sequence will reduce frustration and false starts:

Before Phase 0

  • Rust: work through chapters 1--10 of The Rust Book (ownership, borrowing, structs, enums, error handling). Do not skip chapter 9 (error handling) --- it is essential for writing axum handlers.

  • Svelte: complete the official Svelte tutorial (svelte.dev/tutorial). Focus on reactivity, stores, and component composition. One afternoon is enough.

  • axum: read the axum examples on GitHub (hello world, JWT auth, WebSocket). The tokio async model will feel unfamiliar at first --- lean on .await and let the compiler guide you.

  • sqlx: read the sqlx README and try the query!() macro against a local Postgres. The compile-time checking is its main value --- understand why it requires a live DB at compile time (or use the offline mode with sqlx prepare).

During Phase 1

  • Use thiserror for defining error types and implement axum's IntoResponse for them. This pattern will carry through the entire codebase.

  • Write integration tests from the start --- axum has excellent test utilities (TestClient). Tests will catch borrow checker issues before they become production bugs.

  • For Svelte, start with a flat component structure. Introduce stores only when you need shared state across components that are not parent/child.

General

  • Resist the urge to add complexity early. The generic ruleset interface, the content service, and Valkey all exist in the design but should not be built until the phase that introduces them.

  • Commit at the end of every working session. Use conventional commits (feat:, fix:, chore:) --- the CI pipeline and future changelog generation will benefit.

  • When stuck on the borrow checker: step back, simplify, and re-read the relevant Rust Book chapter. Fighting the borrow checker usually means the design needs adjusting, not the syntax.

16.10 Estimated Phase Effort (Solo)

These estimates assume part-time development (evenings and weekends) and a learning curve on Rust and Svelte. They are intentionally conservative.


# Phase Est. Complexity Risk Weeks


0 Foundations 1--2 Low Low --- mostly tooling and config

1 Vertical Slice 4--6 High High --- Rust + Svelte + TS API all new at once

2 Groups & 3--4 Medium Medium --- Sessions WebSocket in Rust is well-documented

3 Campaign Web App 3--5 Medium Low --- Svelte will be familiar by now

4 Caching 1--2 Low Low --- well-defined scope, good Rust crate support

5 Content Service 4--6 High Medium --- dependency graph logic is the hard part

6 Polish & 3--5 Medium Low --- features Community are additive, no structural changes

Total estimated range: 19--30 weeks of part-time work to a full public release. Phases 0--2 (playable at the table) can be reached in roughly 8--12 weeks.

12. Resolved Decisions & Planned Features

12.1 Resolved

  • DM character visibility: DMs can read full sheet_data for all characters in their group. The GET /groups/:id/characters endpoint returns complete sheet objects to DM-role callers and owner-only data to player-role callers.

  • Multi-DM: not in scope for v1. The data model enforces exactly one dm_user_id per group. This can be revisited post-launch.

12.2 Planned for v2

  • Campaign Journal: a group-level shared notes space. Stored as structured Markdown in a new journal_entries table (group_id, author_user_id, content, created_at). Visible to all group members; editable by the author and the DM. Surfaced in both the web app and the Symbiote.

  • Offline Support: TS.storage caches the last-fetched sheet_data per character. The Symbiote detects network failure and renders a read-only cached sheet with a banner indicating offline status. Writes are queued and flushed on reconnect.

  • Mod.io publishing: once stable, package and submit to mod.io so players can install via the TaleSpire in-game browser.

  • Ruleset marketplace: a community registry where plugin authors can publish new game systems independently of the core project.

  • Additional rulesets: D&D 5e, Pathfinder 2e, and Call of Cthulhu are natural follow-ons after the DSA5e baseline proves the plugin contract.

13. Content Database

The content database is a second PostgreSQL instance connected to the same Axum service via a dedicated sqlx connection pool. It is entirely separate from the campaign database: no foreign keys cross between them, and the two pools are independently configured, migrated, and backed up. The campaign database owns users, groups, and characters. The content database owns every piece of game-system knowledge --- rulebooks, rules, items, monsters, spells, talents, and the dependency graph that links them together.

This separation means the content database can be iterated, exported, or replaced without touching campaign data, and it can eventually serve multiple front-ends (the character editor, a standalone compendium site, a Symbiote reference panel) with identical data.

13.1 Two-Pool Architecture

At startup, the Axum service initialises two sqlx PgPool instances from separate connection strings --- one for each database. Route handlers that need content data receive the content pool via Axum's dependency injection (State extractor). No handler ever touches both pools in the same transaction; cross-database consistency is managed at the application layer, not the database layer.

// axum State setup (simplified)

struct AppState {

campaign_db: PgPool, // DATABASE_URL

content_db: PgPool, // CONTENT_DATABASE_URL

}

Both databases run as separate Kubernetes StatefulSets backed by a managed Postgres service (or separate PVCs if self-hosting). No PVC is needed on the Axum pods themselves --- they remain fully stateless.

13.2 Schema

13.2.1 rulesets


Field Type Required Description


id VARCHAR(100) Yes e.g. 'dsa5e', (PK) 'dnd5e'. Matches ruleset_id in the campaign DB.

name VARCHAR(150) Yes Display name, e.g. 'Das Schwarze Auge 5. Edition'

version VARCHAR(50) Yes Content schema version, semver

description TEXT No

published BOOLEAN Yes false = draft; only maintainers can see

13.2.2 ruleset_roles (contributor access)


Field Type Required Description


ruleset_id VARCHAR(100) (FK) Yes Composite PK

user_id UUID (FK → Yes Composite PK. campaign.users) Cross-DB join done in application code.

role ENUM(contributor, Yes contributor = submit maintainer) only; maintainer = approve + publish

granted_at TIMESTAMPTZ Yes

granted_by UUID Yes user_id of the maintainer who granted this role

13.2.3 content_entries


Field Type Required Description


id UUID (PK) Yes

ruleset_id VARCHAR(100) (FK Yes
→ rulesets)

entry_type VARCHAR(80) Yes e.g. 'talent', 'combat_technique', 'spell', 'item', 'monster', 'rule', 'special_ability'

slug VARCHAR(200) Yes URL-safe identifier, unique per ruleset+type. e.g. 'acrobatics'

name VARCHAR(200) Yes Display name

data JSONB Yes Type-specific payload validated by the ruleset plugin schema

source_ref VARCHAR(200) No Rulebook + page, e.g. 'WdS p.42'

status ENUM(draft, Yes Drives the contribution review, workflow published,
rejected)

created_by UUID Yes user_id of the original author

created_at TIMESTAMPTZ Yes

updated_at TIMESTAMPTZ Yes

13.2.4 content_revisions (edit history)


Field Type Required Description


id UUID (PK) Yes

entry_id UUID (FK → Yes
content_entries)

data_snapshot JSONB Yes Full copy of data at this point in time

changed_by UUID Yes user_id

change_note TEXT No Optional commit message from the editor

created_at TIMESTAMPTZ Yes

13.2.5 content_dependencies (graph edges)


Field Type Required Description


from_entry_id UUID (FK → Yes The entry that content_entries) requires something

to_entry_id UUID (FK → Yes The entry that is content_entries) required

dep_type ENUM(requires, Yes requires = hard enhances, prerequisite; conflicts) enhances = optional synergy; conflicts = mutually exclusive

condition TEXT No Human-readable condition description, e.g. 'FF 13 or higher'

13.2.6 rulebook_sections (structured lore/rules text)


Field Type Required Description


id UUID (PK) Yes

ruleset_id VARCHAR(100) (FK Yes
→ rulesets)

title VARCHAR(300) Yes Section heading

body TEXT Yes Markdown-formatted rules text

parent_section_id UUID (FK, No Enables nested nullable) chapter structure

order_index INTEGER Yes Sort order within parent

status ENUM(draft, Yes
published)

created_by UUID Yes

created_at TIMESTAMPTZ Yes

13.3 Contribution Roles

Two roles govern who can do what to a given ruleset's content. Roles are per-ruleset --- a user can be a maintainer for DSA5e and merely a contributor for a community homebrew system.


Role Assigned by Capabilities


Any registered --- Submit new entries or edits (status = user draft → review)

contributor Maintainer Submit entries; view all drafts for the ruleset; cannot approve or publish

maintainer Admin or other Approve, reject, or publish any maintainer entry; edit without review; manage contributor list

admin System Full access to all rulesets; can create rulesets, promote maintainers

13.4 Contribution & Review Workflow

  • Any registered user submits a new entry or edit. The entry lands in status = 'review'. The submitter cannot publish it themselves.

  • A maintainer for that ruleset reviews the entry in the content editor. They can approve (→ published), reject (→ rejected with a note), or request changes (entry returns to draft with a comment thread).

  • Maintainers can also edit and publish directly without the review step, for trusted bulk imports or corrections.

  • All state transitions are recorded in content_revisions so the full editorial history is auditable.

  • Published entries are immediately available to the character editor and pickers. Drafts and review-queue entries are only visible to contributors and maintainers of that ruleset.

13.5 Integration with the Character Editor

The character editor in the web app (and Symbiote) integrates the content database through three distinct mechanisms, all driven by the active ruleset plugin.

13.5.1 Searchable Pickers

When a field in the character sheet requires selecting a game entity (a talent, weapon, spell, special ability, etc.), the editor renders a picker component rather than a free-text input. The picker calls:

GET /content/{ruleset_id}/entries?type={entry_type}&q={search_term}&limit=20

Results are paginated and returned with name, slug, source_ref, and a summary excerpt from the data blob. Selecting an entry writes only its id and slug to sheet_data --- the full entry is always fetched fresh from the content DB at render time, so corrections to the content database are immediately reflected on existing characters without sheet migration.

13.5.2 Auto-Calculation of Derived Stats

When sheet_data changes, the ruleset plugin's calculate() method runs client-side and updates all derived values in memory before the sheet re-renders. The calculation engine reads base attributes from sheet_data and looks up any content-database entries (e.g. a talent's base attribute formula) to produce final values.

interface Ruleset {

// ... existing methods ...

calculate(sheet: SheetData, entries: EntryMap): DerivedStats

}

EntryMap is a pre-fetched, client-side cache of all content entries referenced by the current sheet, loaded once on sheet open and invalidated on content DB version bump. This means calculations are synchronous and instant --- no round-trip required during editing.

13.5.3 Rule Validation

Before saving, the ruleset plugin's validate() method checks the full sheet against both the structural schema and the dependency graph. Prerequisite checking walks the content_dependencies edges:

  • 'requires' edges: the character must meet the condition (e.g. attribute threshold) or already have the prerequisite entry on their sheet

  • 'conflicts' edges: if the character has entry A, entry B cannot be added

  • 'enhances' edges: informational only; shown as suggestions in the UI but never block saving

Validation errors are displayed inline next to the offending field, with a link to the dependency entry in the compendium so the player understands why the rule applies.

13.6 Content API Endpoints


Method + Path Auth Description


GET /content/:ruleset_id/entries Any user Search/list published entries. Supports ?type=, ?q=, ?limit=, ?offset=

GET /content/:ruleset_id/entries/:id Any user Single entry with full data, source_ref, and dependency list

GET Any user Full dependency subgraph for an /content/:ruleset_id/entries/:id/dependencies entry (BFS up to configurable depth)

POST /content/:ruleset_id/entries Registered Submit a new entry (status = user review)

PUT /content/:ruleset_id/entries/:id Contributor+ Submit an edit (status → review unless caller is maintainer)

POST /content/:ruleset_id/entries/:id/approve Maintainer Approve and publish an entry

POST /content/:ruleset_id/entries/:id/reject Maintainer Reject with a reason string

GET /content/:ruleset_id/entries/:id/revisions Contributor+ Full revision history for an entry

GET /content/:ruleset_id/sections Any user Rulebook sections (tree structure via parent_section_id)

POST /content/:ruleset_id/sections Maintainer Create a new rulebook section

PUT /content/:ruleset_id/sections/:id Maintainer Edit section body or title

GET /content/rulesets Any user List all published rulesets (id, name, version)

POST /content/rulesets Admin Create a new ruleset entry

GET /content/:ruleset_id/queue Contributor+ Review queue: all entries in status = review for this ruleset

13.7 Content Editor Web App

The content editor is a separate Svelte app (content.campaign-manager.example.com) that shares the same auth system and Axum backend. It is the primary tool for maintainers and contributors to manage ruleset content. The character editor web app embeds read-only pickers that link back to the content editor for details.

13.7.1 Routes


Route Description


/ Ruleset selector; shows all rulesets the user has any role in, plus public ones

/:ruleset_id/entries Searchable, filterable entry list. Toggle between published and review queue.

/:ruleset_id/entries/new Entry creation form with type selector; fields driven by ruleset plugin schema

/:ruleset_id/entries/:id Entry detail: full data view, dependency graph visualisation, revision history

/:ruleset_id/entries/:id/edit Edit form with diff preview against last published version

/:ruleset_id/queue Maintainer review queue: one-click approve/reject with comment

/:ruleset_id/sections Rulebook section tree with drag-to-reorder; Markdown editor for body

/:ruleset_id/contributors Maintainer-only: manage contributor and maintainer list

13.7.2 Dependency Graph View

The entry detail page renders an interactive force-directed graph of the selected entry's dependency neighbourhood (requires / enhances / conflicts edges up to 2 hops). This gives maintainers and contributors an at-a-glance view of how tightly coupled an entry is before editing it, and helps players browsing the compendium understand build paths.

13.7.3 Schema-Driven Forms

Entry creation and edit forms are generated from JSON Schema definitions exported by the ruleset plugin (one schema per entry_type). This means the content editor never needs to know DSA5e-specific field names --- it reads the schema, renders the appropriate inputs (numeric, text, enum dropdown, multi-select for dependencies), and validates client-side before submitting. Adding a new entry type to a ruleset only requires updating the plugin schema; the editor UI adapts automatically.

13.8 Kubernetes & Storage Notes

The content database runs as a second StatefulSet (or managed Postgres instance) alongside the campaign database. Both are accessed by the same stateless Axum pods --- no PVC on the application tier. Object storage (S3-compatible) is used for any binary assets referenced by content entries (token images, item artwork) using the same presigned-URL pattern as character portraits.

  • Two separate DATABASE_URL / CONTENT_DATABASE_URL environment variables injected as Kubernetes Secrets

  • sqlx migrations for each database live in separate /migrations/campaign and /migrations/content directories and are run independently at startup

  • Read replicas can be added per-database independently; the content DB is read-heavy and benefits from a replica for picker queries during active sessions

  • Backup schedules can differ: campaign DB (user data) backed up continuously; content DB (curated reference data) backed up daily

14. Resolved Decisions & Planned Features

14.1 Resolved

  • DM character visibility: DMs can read full sheet_data for all characters in their group. The GET /groups/:id/characters endpoint returns complete sheet objects to DM-role callers and owner-only data to player-role callers.

  • Multi-DM: not in scope for v1. The data model enforces exactly one dm_user_id per group. This can be revisited post-launch.

14.2 Planned for v2

  • Campaign Journal: a group-level shared notes space. Stored as structured Markdown in a new journal_entries table (group_id, author_user_id, content, created_at). Visible to all group members; editable by the author and the DM. Surfaced in both the web app and the Symbiote.

  • Offline Support: TS.storage caches the last-fetched sheet_data per character. The Symbiote detects network failure and renders a read-only cached sheet with a banner indicating offline status. Writes are queued and flushed on reconnect.

  • Mod.io publishing: once stable, package and submit to mod.io so players can install via the TaleSpire in-game browser.

  • Ruleset marketplace: a community registry where plugin authors can publish new game systems independently of the core project.

  • Additional rulesets: D&D 5e, Pathfinder 2e, and Call of Cthulhu are natural follow-ons after the DSA5e baseline proves the plugin contract.

End of Document