Files
campaign-manager/campaign_manager_design_v7.md

2930 lines
113 KiB
Markdown

**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/:id: check L1 → check Valkey → query DB →
> backfill
>
> \# On PUT/PATCH /characters/:id: 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