Audience: Maintainers working on system startup, registration, and core object construction.
See also: Architecture Overview, Extension Points, Security Model.
This page is the canonical reference for runtime contracts and invariants. If guidance here conflicts with narrative/how-to pages, this page is authoritative.
System-level contracts (SohlSystem)
Primary file: src/core/logic/SohlSystem.ts
globalThis.sohlis assigned during init (src/sohl.ts) and provides the runtime system instance.- Core config surface (
CONFIG) exposes document classes, data models, sheets, and result/modifier constructors. - Calendar registry (see Extension Points — Calendar Registration).
CONFIG structure
sohl.CONFIG defines which classes are active at runtime:
Modifier classes
| CONFIG key | Default class | Description |
|---|---|---|
CONFIG.ValueModifier | ValueModifier | Base auditable value tracker |
CONFIG.CombatModifier | CombatModifier | Combat-specific modifier |
CONFIG.ImpactModifier | ImpactModifier | Damage/impact modifier |
CONFIG.MasteryLevelModifier | MasteryLevelModifier | Test mastery level modifier |
Result classes
| CONFIG key | Default class | Description |
|---|---|---|
CONFIG.SuccessTestResult | SuccessTestResult | Single test result |
CONFIG.OpposedTestResult | OpposedTestResult | Opposed test result |
CONFIG.AttackResult | AttackResult | Attack resolution (carries impact + aim) |
CONFIG.DefendResult | DefendResult | Defense resolution |
CONFIG.CombatResult | CombatResult | Full combat outcome |
Document sheets
CONFIG.Actor.documentSheets and CONFIG.Item.documentSheets map actor/item type strings to sheet classes.
Invariants
- Core constructor mappings (results/modifiers/data models) must be available before sheet and runtime usage.
- Common code should construct classes through
sohl.CONFIGmappings when available.
Data model contract (SohlDataModel)
Primary file: src/core/foundry/SohlDataModel.ts
SohlDataModel is the typed data-layer wrapper over Foundry TypeDataModel.
Key contracts:
- Each data model class declares static
kindand localization prefixes. - Logic object is created lazily through
create(...)+logicaccessor. fromData(...)resolves model class bykindacross configured document families and normalizes serialized JSON forms.
Updating array fields: write the whole array, never an element by index
Never target a single element of an ArrayField by index in an update()
payload — e.g. update({ "system.structure.parts.2.heldItemId": id }).
Foundry expands the dotted key to { parts: { 2: {…} } } and rebuilds the array
field from that sparse map, truncating the array and default-filling every
element that wasn’t named. A partial write meant to touch one field of one
element silently destroys every other element (e.g. wiping every body part’s
shortcode, canHoldItem, and locations). This corrupted persisted anatomy
in production; see issue #247.
To change a subset of an array’s elements, source the full canonical array
from the DataModel and write it back whole, with only the target element(s)
modified. The BodyStructure update-builders are the template:
addPartUpdate, removePartUpdate, and
the general setPartFieldsUpdate (used by
holdItem/releaseItem and BodyPart.addLocationUpdate/
removeLocationUpdate).
Two reasons this is easy to miss:
- A valid value is required to trigger it. If the written value fails field
validation (e.g. an invalid
DocumentIdFieldid), Foundry drops that field and the update becomes a no-op, leaving the array intact — so ad-hoc tests with placeholder ids look fine while real ids corrupt. - Form submission is unaffected. Sheets serialize the complete array (every
element, every field), so
partsis always replaced wholesale. Only hand-built partial updates outside a form hit this.ObjectFielddicts keyed by id (e.g.system.strikeModes.<id>.<field>) are also safe — object partial-merge is fine; the hazard is arrays specifically.
Reserved flags (flags.sohl.*)
| Flag | Type | On | Meaning |
|---|---|---|---|
flags.sohl.docArchetype | number | Actor / Item | Marks the document as a Create-dialog archetype (a populated starting template) and carries its priority. See Extension Points → Create-dialog archetypes. |
docArchetype is discovered across the world directory and matching compendium
packs, deduped by system.shortcode, and resolved by priority desc, source tier
asc (world < system < module), UUID — the Foundry-free
archetype module. The flag is stripped when an archetype
is instantiated (Create dialog seed, drop-to-embed) and preserved when a
document is copied verbatim (Import, Duplicate); the strip lives at those specific
entry points and never in _preCreate (which cannot tell the two apart).
Document/DataModel/Logic contract
SoHL separates persistence from behavior:
- Document (
SohlItem,SohlActor) — integrates with Foundry document APIs. - DataModel (
document.system) — defines persisted fields/schema only. - Logic (
document.system.logic) — contains lifecycle methods, rules behavior, and derived properties.
Concrete example (Skill type):
SohlItemwithtype = skillSkillDataModelonsystem(persisted fields)SkillLogiconsystem.logic(lifecycle, calculations)SkillSheet(UI/editor)
Guideline: Stored fields in DataModel, derived behavior in Logic, integration helpers in Document, presentation in Sheet.
Sheet mixin contract
SohlDataModel.SheetMixin(...) provides shared sheet behavior:
- Context build (
config,system,effects, transferred effects) - Drag/drop hooks and routing by dropped document type
- Item/effect context-menu wiring through
SohlContextMenu
Use this mixin for consistent behavior across actor/item/effect sheets.
Sheet DOM markers: how a menu predicate finds its document
A context-menu predicate — an entry’s condition, or an action’s visible /
trigger — is handed only the clicked element. Its itemLogic and actorLogic
bindings are resolved from the DOM by walking up from that element:
| Binding | Resolved from |
|---|---|
actorLogic | the nearest [data-actor-id] ancestor |
itemLogic | the nearest [data-item-id] ancestor, looked up on the resolved actor |
Two obligations follow, and a surface that renders rows must meet them:
- Every actor sheet root carries
data-actor-id, and an owned item sheet’s root carries its owner’s. Both sheet bases stamp it in_onRender. Without that marker no row can resolve its actor, the item lookup that goes through the actor comes up empty, and every action whose predicate namesitemLogicsilently disappears from the menu — the entry is hidden, not errored, so the failure is invisible. - A row should carry
data-uuidalongsidedata-item-idwhere it can.resolveContextItemfalls back to the row’s own UUID when the actor route yields nothing, which is what lets an unowned (world/directory) item row bind at all.
When adding a row template or a new sheet surface, emit both markers and assert
the rendered menu in an e2e spec — a synthetic closest() stub supplies the
very markers a real sheet might be missing, so it cannot catch the omission.
Constants and metadata contract
Primary file: src/utils/constants.ts
- Canonical kind IDs (
ACTOR_KIND,ITEM_KIND). - Metadata maps (
*_METADATA) for registration and UI lookups. - Test/modifier enums used throughout result/modifier pipelines.
Changing constants is a high-impact operation: treat as migration-sensitive.
The grounding rule: reference on the wire, live object in memory
One rule governs how SoHL represents state across the DataModel and Logic/entity layers, in both directions:
The serialized (wire) form of anything shared is a reference — a UUID, id, shortcode, or
PointerData. The in-memory form is the resolved live object — an entity, an item/actor Logic, aStrikeMode, a table — rehydrated by the Logic/entity constructor.
- DataModel /
Datainterfaces hold pointers. A persisted field is a reference:combatantUuid(not the live combatant),heldItemIdon a body part, a cohort member’sshortcode, a strike mode’sPointerData, a Foundry document reduced to aClientDocumentUUID bydefaultToJSON. - Logic classes and entities rehydrate those pointers into real objects in
their constructor and hold the live object thereafter:
fvttLogicFromUuidSyncturns a UUID into a Logic;MeleeStrikeMode.fromPointerDataturnsPointerDatainto a live strike mode;defaultFromJSONturns a__kindtag into the concrete class. TheparentLogic is supplied on revival (options.parent), never carried in the payload.
Two failure modes break the rule — watch for both in review:
- A pointer kept in memory — a runtime field typed as
PointerData/uuid/id that is never resolved, so consumers can’t use it and the real data gets denormalized and stored elsewhere. - A live object or config put on the wire — serializing a resolved object, or embedding static config (a table, a function body) in a payload, instead of a reference the receiver resolves locally.
The corollary is minimality: store only the reference and the genuinely transient data (the dice roll, an evaluated-snapshot success level). Never serialize what an in-memory object recomputes — anything calculable is derived on read, not stored.
Entity serialization contract
Primary files: src/entity/SohlEntity.ts, src/utils/helpers.ts, src/utils/kindRegistry.ts
Domain entities — results (SuccessTestResult, AttackResult, …), modifiers (ValueModifier, ImpactModifier, …), and dice (SimpleRoll) — all extend SohlEntity and share one serialization mechanism. There is no reflective serializer; a class serializes exactly what its toJSON emits. This section applies the grounding rule above to those entities.
- Ownership. Every
SohlEntityrequires aparentLogic (options.parent); the constructor throwsSohlEntity requires a parentotherwise.parentis transient — never serialized — and is re-supplied on revival. - Two constructor forms. The constructor is overloaded (matching the
clone(parent)shorthand below):new X(parent)is shorthand for an empty entity owned byparent, whilenew X(data, options)supplies persisted state plus options (options.parentrequired). The base normalizes the overloaded first argument withSohlEntity.dataOf/SohlEntity.optionsOf, which use theisA(x, "SohlLogic")brand check — not duck-typing — so a data bag that merely carries aparentkey is never mistaken for a Logic. Subclasses that construct usefully from{}(ValueModifier,MasteryLevelModifier,CombatModifier,ImpactModifier,SimpleRoll,TestResult,SuccessTestResult) expose both overloads; classes that require non-emptydata(the body classes, strike modes, and the non-empty results) keep the single(data, options)form. A subclass adopts the shorthand with a fixed template — declare the two overloads, then normalize inside thesuper(...)arguments:Normalizing inside theconstructor(parent: SohlLogic<any>); constructor(data: Partial<X.Data>, options: Partial<X.Options>); constructor( dataOrParent: SohlEntity.DataOrParent<X.Data> = {}, options: Partial<X.Options> = {}, ) { super( SohlEntity.dataOf<X.Data>(dataOrParent), SohlEntity.optionsOf<X.Options>(dataOrParent, options), ); const data = SohlEntity.dataOf<X.Data>(dataOrParent); // …rehydrate fields from `data` as before… }super(...)argument expressions (rather than in statements beforesuper) keeps the class immune to the “supermust be the first statement” rule. - Serialize / revive through
defaultToJSON/defaultFromJSON.JSON.stringify(defaultToJSON(entity))is the wire form;defaultFromJSON(parsed, { parent })rebuilds it.defaultToJSONhonors each object’stoJSON(and stamps a__kindtag through theSohlEntitychain);defaultFromJSONrevives nested__kind-tagged children bottom-up, then callsnew Ctor(data, { parent })via the kind registry. - Curated
toJSON,Data-shaped. Each subclass that adds state overridestoJSON(chaining...super.toJSON()) and emits keys matching itsDatainterface in persisted representation — a uuid/shortcode where the implementation holds a resolved object (combatantUuid, not the live combatant), the raw value where a getter normalizes it. The governing rule:toJSON()output must be validdatafor the constructor, i.e.new Ctor(x.toJSON(), { parent })reconstructsx. Emit special-typed fields (Map,Set,Date, nested entities) throughdefaultToJSONor the child’stoJSONso the tree is fully JSON-safe. Do not re-emit a value that a nested modifier already carries (e.g. a situational modifier folded intomasteryLevelModifier) — it would double-apply on revival. - Register for revival. A class rehydrates to its concrete type only if it calls
registerKind(X.Kind, X). Without it,defaultFromJSONleaves the serialized form as inert data — dropping deltas, collapsing computed values back to the base. - Cloning is explicit.
entity.clone(parent)deep-copies and re-parents; there is no implicit “reuse my parent”. Useentity.clone(entity.parent)for a same-owner copy; cloning without a resolvable parent throws (aSohlEntitymust have one). - A Logic is a reference, not a payload.
SohlLogic.toJSONemits a compact{ uuid, name, kind }reference. A logic wraps a live Foundry document and is re-resolved by uuid (fvttLogicFromUuidSync), never revived from its own JSON — which is why an entity’s owningparentis supplied on revival rather than serialized.
Entity class registry
Primary files: src/entity/entityRegistry.ts (the registry), src/entity/registry.ts (the eager-load barrel).
The curated set of constructable entity-layer classes — modifiers (ValueModifier, ValueDelta, CombatModifier, ImpactModifier, MasteryLevelModifier), results (TestResult, SuccessTestResult, OpposedTestResult, ImpactResult, AttackResult, DefendResult, CombatResult), strike modes (StrikeModeBase, MeleeStrikeMode, MissileStrikeMode), SohlAction, and body modeling (BodyStructure, BodyPart, BodyLocation) — is overridable. A variant module can subclass any of them and swap in its subclass, and every construction across the system then produces the subclass.
Overridability is all-or-nothing on construction discipline: a class is overridable only where every construction site resolves it through the registry rather than a bare new. A single stray new SuccessTestResult(...) would silently produce the base class even after a module registered an override, so the discipline is enforced by an ESLint rule (below), not left to convention.
The registry surface
The entity surface is a frozen, getter-backed view over a module-private backing record, exposed the same way inside and outside SoHL:
entity.<ClassName>— a getter returning the currently-registered class (the override if one was registered, else the canonical SoHL base). Because it is a getter, every access and every construction site routed through it picks up a later override automatically.entity.register(name, cls)— install an override.clsmust extend (or be) the canonical base forname; the call throws on an unknown name, a class that does not extend the base, or a canonical base that has not yet loaded. Call it from a module’sinit/setuphook, before the first construction of that class.entity.base(name)— the canonical SoHL base forname, ignoring any override. Useful for a module that wants toextends entity.base("SuccessTestResult")rather than whatever is currently registered.
register/base are non-enumerable, so Object.keys(entity) lists only class names.
Classes populate the registry by self-registration: each class module calls registerEntity("MyClass", MyClass) at the bottom (mirroring registerKind for serialization). The backing leaf entityRegistry.ts value-imports none of the classes — it only import types them for the surface’s types — which is what keeps it free of load cycles. The first registration for a name is captured as the canonical base; register overrides only the current binding.
The two construction mechanisms
Which one to use depends on whether you are inside or outside SoHL:
- Inside SoHL —
import { entity } from "@src/entity/registry"(or, for the few base classes below, from the leaf@src/entity/entityRegistry), thennew entity.X(...). A static import, so it resolves purely through the module graph — no reliance on any runtime global, which is what lets unit tests construct these classes with nosohl.entitywired. - Outside SoHL (macros, variant modules) — the same surface is published on the runtime global as
sohl.entity. Construct withnew sohl.entity.X(...), subclass withclass Y extends sohl.entity.X {}, and override withsohl.entity.register(...).
Both mechanisms read the identical backing record, so an override registered via sohl.entity.register is honored no matter which one constructs the object.
The barrel vs. the leaf. registry.ts is an eager-load barrel: its side-effect imports pull in every class module so all self-register, and it re-exports the surface. Most internal code imports entity from the barrel. But a class that is itself the base of a registered class cannot import the barrel — the barrel eagerly imports that class’s own subclasses, so a re-entrant load would evaluate class Sub extends Base while Base is still mid-load → TypeError: Class extends value undefined. Those base classes (ValueModifier, MasteryLevelModifier, SuccessTestResult, OpposedTestResult, StrikeModeBase) import entity from the cycle-free leaf entityRegistry.ts instead, and add a bare side-effect import of each class they construct so those targets self-register even when the barrel has not been loaded (e.g. in a bare unit test). Each carries a header block explaining this.
Enforcement
An ESLint no-restricted-syntax rule (scoped to src/**/*.ts in eslint.config.js) flags a bare new X(...) for any registered class name and steers to new entity.X / new sohl.entity.X. Member-expression callees (entity.X, sohl.entity.X) are not matched, so the two blessed forms pass. Tests are exempt — they construct the concrete classes directly to exercise them.
Chat-card dispatch contract
Chat-card buttons (inside .card-buttons) and a.edit-action links are routed by the renderChatMessageHTML hook in src/sohl.ts. The handler document is resolved from the clicked element’s dataset by resolveChatCardHandlerUuid(dataset) (src/document/chat/chat-card-dispatch.ts), which normalizes the differing attribute conventions across cards with this precedence:
data-doc-uuid(standard-test, fate, edit-action cards)data-handler-uuid(damage, injury, attack-result cards)data-handler-actor-uuid(attack-card defender responder)data-action-handler-uuid(opposed-request / opposed-result cards)
The resolved document’s onChatCardButton(btn) (or onChatCardEditAction) is then invoked; handlers switch on btn.dataset.action. New card buttons must emit one of the attributes above and add an action case rather than introducing a new attribute name. For the procedure, see Extension Points — Adding a chat-card button.
Authorization: the handler document’s owner acts
A chat-card action is handled by the actor it addresses, and only a client that owns that actor may run it (a GM owns all). Buttons that drive a flow — roll damage, respond to an attack, resume an opposed test — mutate the handler’s own actor state, so under actor-state sovereignty authorization is exactly document ownership. This rule has two sides, both keyed on the resolved handler document’s isOwner:
- Render-time (visibility). When a card renders,
gateAutomatedDefenseButtons(src/document/chat/chat-card-gating.ts) resolves each defender-response button’sdata-handler-actor-uuidand removes the buttons a non-owner can’t use (and, for the owner, gates by capability/incapacitation). This is UX only — a per-client cosmetic filter. - Click-time (authorization). The render gate is trivially bypassed by a synthesized click or a direct
doc.onChatCardButton(...)call, so the real boundary is at dispatch:resolveAuthorizedChatCardHandler(dataset, resolveDoc)(chat-card-dispatch.ts) resolves the handler document and returns it only ifdoc.isOwner— otherwise the click is ignored, before any dialog,buildActionScoperevival, or intrinsic logic runs. EachonChatCardButtonhandler additionally re-checksthis.isOwneron entry, so a direct call is refused too (defense-in-depth). This closes issue #167.
Foundry’s own document-ownership check still guards the final persisted write; the authorization gate exists to refuse entering the pre-write flow (dialogs, scope revival, intrinsic actions) for an unauthorized client. Both resolveAuthorizedChatCardHandler and the render gate take the Foundry lookup (fromUuidSync) as an injected parameter, so the authorization logic is Foundry-free and unit-tested.
A card button carries three kinds of data, and only the first two are flat attributes:
- Display fields — rendered into the card body; never read by dispatch.
- Routing metadata —
data-actionplus onedata-*-handler-uuid(above), read off the dataset before scope exists. - Scope — the per-action payload, carried as a single
data-scopeattribute.
The action context (SohlActionContext)
SohlActionContext is a runtime value object, not a SohlEntity: it is built fresh at every dispatch (new SohlActionContext({ speaker, type, scope })) and is never revived from its own JSON. Only its scope crosses the client boundary; the rest — speaker, target, skipDialog, noChat — is runtime state. scope stays a per-action ContextScope interface, not an entity: its entity-valued members already round-trip through defaultToJSON / defaultFromJSON, so the bag itself needs no toJSON and no owning parent. Fork a received context with context.clone(overrides?).
Passing scope to a button
An action’s scope travels as one serialized data-scope blob, not a spray of bespoke attributes:
- The card-creation logic sets
data-scope=JSON.stringify(defaultToJSON(scope)). In templates the registered{{toJSON scopeData}}helper does theJSON.stringify, so the card-data field stays an object (scopeData: defaultToJSON({ attackResult })) and the template rendersdata-scope="{{toJSON scopeData}}". - The handler revives it through
buildActionScope(dataset, parent)(src/utils/helpers.ts) —defaultFromJSON(JSON.parse(dataset.scope), { parent })— so the action reads live objects (context.scope.attackResult,context.scope.opposedTestResult), never a per-payload JSON string. - Do not add per-payload
data-*-jsonattributes or read individual pieces off the dataset; put the whole payload indata-scopeand letdefaultToJSON/defaultFromJSONcarry it (see Entity serialization contract).
Safe extension checklist
To add a new actor or item type, follow the worked example in Extension Points — Actor and Item type extension: add the kind constant + metadata → data model + logic + sheet → register in SohlSystem → verify fromData(...)/drag-drop/sheet contexts resolve → validate startup.