Skip to content

System API 0.1 — De Facto Specification

Status: Descriptive. Documents the System contract as it ships today (retroactively numbered 0.1; the surface is unversioned in code). Written from production source on branch version/0.40.0. Quirks/drift flagged ⚠︎.

Supersedes as the contract reference the design narrative in temp_to_be_analyzed/architecture/system.md (kept for the 3-layer mental model; note the drift listed in §7).

1. What the System API is — and the "two APIs, one word" caveat ⚠︎

A System (D&D 5e 2014/2024, System 8, custom) is described by three layers (per system.md), but in code those layers are two very different APIs that happen to share the word "System":

  1. Data plane (Layers 1–2): the trait/fixture REST API. Fully built, DB-backed, dual-ownership (system-default vs. campaign-override), seeded from TypeScript fixtures. This is the bulk of the surface and is consumed broadly (campaigns, tokens, maps, lighting, actions).
  2. Logic plane (Layer 3): the SystemModule interface. A lazy-loaded TypeScript character-logic module. It has no REST surface and is consumed only by the Character Sheet UI — so it is really the foundation of the Character Sheet API, not a general system concern (see §8).

2. Version

Unversioned — no SYSTEM_API_VERSION constant exists. Three version-ish fields, only one enforced:

Field Type Location Enforced?
SystemModule.version free-text string ('1.0.0') systems/types.ts:269 ❌ no consumer reads it
SystemFixture.version number (5) types/systemFixture.ts:26 ✅ seeder compares vs. fixture_versions table to decide re-seed (seedSystems.ts:388-396)
MODULE_API_VERSION '1.4.0' module.ts:436 ✅ but versions the module SDK, not this API

So: fixture data is versioned (integer, enforced); the code module carries an unenforced string; the API contract itself has no version gate.

3. Logic plane — SystemModule (packages/shared/src/systems/types.ts)

Export Line Purpose
SystemModule :261 Top-level: id, name, version, description, abilityScores, classes?, validateHp?, suggestions?, classAttributePriorities?, applyTemplateStats?
AbilityScoreConfig :194 Ability rules + validateAbilityScores / validateSingleAbility / getMaxBaseScoreAtLevel
SystemClass :246 Class (name, description?, hitDie?, savingThrowProficiencies?, spellcastingAbility?)
SuggestionEngine :150 suggestAttributes / suggestFeats / suggestPowers
ClassAttributePriorities :176 Per-class primary/secondary/tertiary abilities
ApplyTemplateStatsResult :257 Discriminated union from applyTemplateStats
ValidationIssue / AbilityScoreValidation / HpValidation / AbilityScoreChoice :21 / :32 / :45 / :57 Validation result types
AttributeSuggestion / FeatSuggestion / PowerSuggestion / SuggestionContext :86 / :102 / :120 / :138 Suggestion result/input types
SystemModuleRegistry :296 { [systemId]: () => Promise<SystemModule> }

Character Sheet / Monster / Content API members (MODULE_API_VERSION 1.5.0, all optional, additive). SystemModule gained the projection/generation/content surfaces; see the dedicated specs for detail:

Member(s) API Spec
sheetSchema CS-1 declarative sheet vocabulary character-sheet-api-0.1.md §3
construct, getAttributes, getTraits, getActions, getDerivedStats CS-2 read projection ”
projectToken CS-6 Character→Token projection ”
validate, validateEdit, applyEdit (+ EditIntent) CS-3 / CS-8 (⚠︎ Edit-Mode types-only) ”
autoFill CS-4 automation ”
contentProvider CS-5 → Content API content-api-0.1.md
listTemplates, generateCharacter M-1 / M-2 monster generation monster-api-0.1.md
campaignConfigurationSchema, getCampaignCatalogue (1.38.0, CC.1) Campaign configuration — the tabletop settings a system lets a campaign decide (seven kinds in groups, defaults at read, help text), and the catalogues a filter ranges over packages/shared/src/systems/campaignConfiguration.ts; roadmap campaign-configuration-settings

The Character type it operates on is imported from types/character.ts:36 — it is system-agnostic; SystemModule is the interpretation layer.

Registry / loading (systems/index.ts): systemRegistry (:24) statically registers only dnd5e-2014 and dnd5e-2024; loadSystemModule(id) (:109) is cache-first and falls back to moduleRegistry.getSystemModule(id).loadModule() (:129) for module-provided systems (this is how System 8 loads — it is an external module, not in this repo ⚠︎). getLoadedSystemModule (:148), isSystemAvailable (:155), getAvailableSystems (:39), systemInfoList/getSystemInfoList (:57/:79, the UI list; splices module systems in before custom).

4. Data plane — Systems core REST (routes/systems.ts, mounted /api/systems)

Method Path Purpose Line
GET /api/systems List systems (DB rows merged with un-seeded DEFAULT_SYSTEMS) :39
GET /api/systems/:id One system (fallback to DEFAULT_SYSTEMS) :68
POST /api/systems Create user system :107
PUT/PATCH /api/systems/:id Update metadata :141/:174
DELETE /api/systems/:id Delete + cascade trait rows :181
POST /api/systems/:id/copy Deep-copy system incl. traits :223
POST /api/systems/:id/restore-defaults Re-seed bundled system traits from fixture :401
GET /api/systems/:id/campaign-count Count campaigns using system :497
POST /api/systems/:id/sync-campaigns Remove campaign trait copies shadowing defaults (dryRun) :527

5. Data plane — Trait CRUD (system-level & campaign-level)

System-level trait CRUD — generic createTraitRouter factory (routes/systemTraits.ts:41), mounted /api/systems/:systemId/<trait> (all behind requireAuthWithImpersonation). Each exposes GET /, GET /:id, POST /, PUT /:id, PATCH /:id, DELETE /:id. Ten traits: prop-types, property-definitions, conditions, bars, light-source-types, vision-types, functions, actions, damage-types, currencies. ⚠︎ Entirely undocumented in system.md.

Campaign-level (inherited) trait endpoints — one router per trait, mounted under /api/campaigns/:campaignId/<trait> (conditions is the exception: /api/conditions?campaignId=). Files: propTypes.ts, propertyDefinitions.ts, bars.ts, conditions.ts, lightSourceTypes.ts, visionTypes.ts, campaignFunctions.ts, campaignActions.ts, damageTypes.ts, currencies.ts.

  • Dual-ownership merge (verified propTypes.ts:49-79): with a systemId, query OR(campaignId=X, systemId=…), annotate each row isSystemDefault = (systemId != null && campaignId == null) and isEditable = (campaignId === X).
  • 403-on-system-row (verified propTypes.ts:168-170): writes reject when existing.systemId && !existing.campaignId.

change-system (campaigns.ts:447): body { newSystemId, mergeNewDefaults, deleteOldDefaults, overwriteExisting }; optionally deletes old-default copies, copies new defaults to campaign level, then updates campaign.systemId. ⚠︎ Copies only 7 of 10 trait types (omits campaign_actions, damage_types, currencies) — likely a bug (§7).

6. Data plane — Trait tables & seeding

Tables (all in dbSchema.ts, sqlite + pg mirror), each with dual-ownership system_id/campaign_id columns: systems, prop_types, property_definitions, conditions, bars, light_source_types, vision_types, campaign_functions, campaign_actions, damage_types, currencies, plus fixture_versions (seed tracking).

Seeding (seedSystems.ts): seedAllSystems(force?) (:373, also the startup call at index.ts:843) pulls getAllSystemFixtures() (built-in + module-provided), compares each fixture.version to the fixture_versions table, and seeds/re-seeds accordingly. Prop-types/property-definitions/bars get namespaced ids (${systemId}_${id}) so multiple systems can define e.g. hp without PK collision (buildNamespacedTraitRows, :57).

7. Drift vs. system.md

  1. ⚠︎ System 8 is no longer bundled. system.md lists system8/system8.ts in the repo; it has been extracted to @purple-cat-games/system8 and loads via moduleRegistry. The systemRegistry snippet (system.md:616) is wrong for current code (2 entries, not 3).
  2. ⚠︎ System-level trait CRUD (/api/systems/:systemId/*, 10 routers) is absent from the doc.
  3. ⚠︎ 3 extra trait types in code not in the doc's Layer-2 list: actions (campaign_actions), damage_types, currencies (+ fixture_versions, baseCurrency).
  4. ⚠︎ Fixture packaging claim outdated: doc says traits seed via SQL migrations 0014–0018; actual mechanism is TS fixtures + seedSystems.ts + version-gated fixture_versions.
  5. Doc omits newer fields (aliases, showOnHover, bar colorScale*, SystemClass.savingThrowProficiencies/spellcastingAbility, applyTemplateStats) and the custom no-validation system behavior.

8. Overlap with the Character Sheet API — the key structural finding

SystemModule (Layer 3) is almost entirely a Character-Sheet concern. Its sole consumer is CharacterSheet.tsx (loadSystemModule at :463; abilityScores/suggestions/classes/ applyTemplateStats at :474–:1021). No server code and no other UI consumes its logic members. Classification:

  • Foundational (this System API): Layer 1 metadata (ISystem) + Layer 2 trait tables/REST — consumed app-wide.
  • Character-sheet-specific (belongs to the Character Sheet API): abilityScores, classes, validateHp, suggestions, classAttributePriorities, applyTemplateStats, and all validation/suggestion types. These drive one screen and should arguably be documented/versioned under a Character-Sheet system-adapter contract, not "System API."

9. Known gaps

  1. change-system migrates only 7/10 trait types (§5) — actions/damage-types/ currencies silently dropped on system switch.
  2. No API version / negotiation; SystemModule.version is read by nothing.
  3. ⚠︎ system-level trait writes are under-guarded: the generic factory only enforces same-system ownership, not "bundled system is read-only" — the server will happily PATCH /api/systems/dnd5e-2014/bars/:id. Bundled protection relies on the client honoring isBundledSystem (which excludes system8).
  4. Two parallel trait-copy implementations (systems.ts copy/restore vs. campaigns.ts change-system) with divergent field sets — divergence already realized in gap #1.
  5. Legacy DND5E_* constants in types/system.ts duplicate fixture data (hardcode systemId:'dnd5e-2014' even for 2024) — pre-fixture leftovers.