Daggerheart Event Timeline Contract
This document maps high-traffic Daggerheart mechanics onto the canonical write path:
request -> command -> decider -> event append -> projection apply policy
Use this as the onboarding contract for new mechanics and for review of existing paths.
This document is intentional/mechanic mapping guidance, not a generated type inventory. For exact payload fields and emitter references, use:
Command/Event Timeline Map
| Mechanic | Command Type(s) | Emitted Event Type(s) | Projection Targets | Apply policy notes | Required invariants |
|---|---|---|---|---|---|
| Action roll resolution | action.roll.resolve | action.roll_resolved | Event journal (no direct Daggerheart projection mutation) | Request path records event; projection apply is skipped for this envelope | Campaign/session valid; roll payload valid; command must emit event |
| Roll outcome finalization | action.outcome.apply | action.outcome_applied | Event journal (plus follow-on Daggerheart/system commands) | Request path records outcome event; projection apply is skipped for this envelope | Roll event exists and matches session; no duplicate/bypass apply |
| First-session Daggerheart bootstrap | readiness-owned session.start workflow hook | sys.daggerheart.gm_fear_changed | Daggerheart snapshot (gm_fear) | Bootstrap events append atomically alongside campaign.updated and session.started when a draft campaign starts its first session | Fear seed equals created PC count only; no reseed on later session starts |
| Roll outcome rejection | action.outcome.reject | action.outcome_rejected | Event journal only | Request path records rejection event; projection apply is skipped for this envelope | Rejection reason/code is explicit and correlated to the originating roll |
| Outcome-driven GM Fear update | sys.daggerheart.gm_fear.set | sys.daggerheart.gm_fear_changed | Daggerheart snapshot (gm_fear) | Inline apply depends on runtime mode; outbox mode must not inline apply in request path | Fear bounds and spend/gain checks enforced |
| Typed GM Fear spend | sys.daggerheart.gm_move.apply | sys.daggerheart.gm_move_applied, sys.daggerheart.gm_fear_changed | Event journal audit trail plus Daggerheart snapshot (gm_fear) | Daggerheart command appends both events atomically; transport may additionally open session gate + GM spotlight for interrupt-style direct moves | Spend target must be supported and valid; direct custom moves require description; Fear spend must be positive and available |
| Outcome-driven character state patch | sys.daggerheart.character_state.patch | sys.daggerheart.character_state_patched | Daggerheart character state | Inline apply mode-controlled | Patch payload must include meaningful deltas |
| Hope spend transform | sys.daggerheart.hope.spend | sys.daggerheart.character_state_patched | Daggerheart character state (hope) | Inline apply mode-controlled | Spend amount is positive, actor/target scope is valid, and resulting hope remains within bounds |
| Outcome-driven condition change | sys.daggerheart.condition.change | sys.daggerheart.condition_changed | Daggerheart character conditions | Inline apply mode-controlled | Normalized set diff; no empty/invalid conditions |
| Session gate for GM consequence | session.gate_open, session.spotlight_set | session.gate_opened, session.spotlight_set | Session gate + spotlight projections | Inline apply mode-controlled | One open gate at a time; request/session correlation |
| Character damage apply | sys.daggerheart.damage.apply | sys.daggerheart.damage_applied | Daggerheart character HP/armor | Inline apply mode-controlled | Campaign system is Daggerheart; damage payload valid; emits event |
| Multi-target damage apply | sys.daggerheart.multi_target_damage.apply | N × sys.daggerheart.damage_applied | Per-target Daggerheart character HP/armor | Inline apply mode-controlled | All targets validated atomically; emits N damage_applied events in single batch via DecideFuncMulti |
| Adversary damage apply | sys.daggerheart.adversary_damage.apply | sys.daggerheart.adversary_damage_applied | Daggerheart adversary HP/armor | Inline apply mode-controlled | Adversary exists in session; payload valid; emits event |
| Rest | sys.daggerheart.rest.take | sys.daggerheart.rest_taken, zero or more sys.daggerheart.downtime_move_applied, optional sys.daggerheart.campaign_countdown_advanced | Daggerheart snapshot, participant state updates, and campaign countdown state | Inline apply mode-controlled | Rest type valid; participant selections valid for effective rest type; rest, downtime, and countdown consequences emit atomically from one command decision |
| Temporary armor apply | sys.daggerheart.character_temporary_armor.apply | sys.daggerheart.character_temporary_armor_applied | Daggerheart temporary armor buckets and armor totals | Inline apply mode-controlled | Source/duration/amount validation; emits event |
| Loadout swap and associated resource mutation | sys.daggerheart.loadout.swap, sys.daggerheart.stress.spend | sys.daggerheart.loadout_swapped, sys.daggerheart.character_state_patched | Daggerheart character loadout-facing stress/state | Inline apply mode-controlled for Daggerheart events | Recall cost bounds; stress spend consistency |
| Character conditions apply endpoint | sys.daggerheart.condition.change, sys.daggerheart.character_state.patch (life state updates) | sys.daggerheart.condition_changed, sys.daggerheart.character_state_patched | Character conditions/life state | Inline apply mode-controlled | No-op updates rejected; roll correlation checked when provided |
| Stat modifier apply | sys.daggerheart.stat_modifier.change | sys.daggerheart.stat_modifier_changed | Daggerheart character stat modifiers (base traits + derived stats) | Inline apply mode-controlled | No-op updates rejected; normalized set diff; rest-triggered clearing |
| Adversary condition changes | sys.daggerheart.adversary_condition.change | sys.daggerheart.adversary_condition_changed | Adversary conditions | Inline apply mode-controlled | No-op updates rejected; normalized set required |
| Story note append | story.note.add | story.note_added | Event journal narrative stream | Journal-only apply path | Note payload must be non-empty and campaign-scoped |
| Scene countdown create/advance/resolve/delete | sys.daggerheart.scene_countdown.create, sys.daggerheart.scene_countdown.advance, sys.daggerheart.scene_countdown.trigger_resolve, sys.daggerheart.scene_countdown.delete | sys.daggerheart.scene_countdown_created, sys.daggerheart.scene_countdown_advanced, sys.daggerheart.scene_countdown_trigger_resolved, sys.daggerheart.scene_countdown_deleted | Active-scene Daggerheart countdown projections | Inline apply mode-controlled | Scene ownership must be explicit; advances are positive remaining-value reductions; pending triggers must resolve before further advancement |
| Campaign countdown create/advance/resolve/delete | sys.daggerheart.campaign_countdown.create, sys.daggerheart.campaign_countdown.advance, sys.daggerheart.campaign_countdown.trigger_resolve, sys.daggerheart.campaign_countdown.delete | sys.daggerheart.campaign_countdown_created, sys.daggerheart.campaign_countdown_advanced, sys.daggerheart.campaign_countdown_trigger_resolved, sys.daggerheart.campaign_countdown_deleted | Campaign-scoped Daggerheart countdown projections | Inline apply mode-controlled | Campaign countdowns stay out of scene boards; long-rest automation emits advance events; pending triggers must resolve before further advancement |
| Adversary create/update/delete | sys.daggerheart.adversary.create, sys.daggerheart.adversary.update, sys.daggerheart.adversary.delete | sys.daggerheart.adversary_created, sys.daggerheart.adversary_updated, sys.daggerheart.adversary_deleted | Daggerheart adversary projections | Inline apply mode-controlled | Session-scoped adversary integrity and payload validation |
| Adversary feature staging/apply | sys.daggerheart.adversary_feature.apply | one or more of sys.daggerheart.adversary_updated, sys.daggerheart.adversary_damage_applied, sys.daggerheart.adversary_condition_changed, sys.daggerheart.damage_applied, sys.daggerheart.character_state_patched | Daggerheart adversary state plus any immediate owner/target consequences | Inline apply mode-controlled; GM Fear spends may chain gm_move.apply then adversary_feature.apply in the same request path | Typed feature payload must stage or consume real feature state; no-op feature applies are rejected; consequences must remain replay-safe |
| Environment entity create/update/delete | sys.daggerheart.environment_entity.create, sys.daggerheart.environment_entity.update, sys.daggerheart.environment_entity.delete | sys.daggerheart.environment_entity_created, sys.daggerheart.environment_entity_updated, sys.daggerheart.environment_entity_deleted | Daggerheart environment entity projections | Inline apply mode-controlled | Entity must remain session-scoped; catalog environment identity copied at create; update/delete operate only on instantiated runtime entities |
| Character profile replace/delete | sys.daggerheart.character_profile.replace, sys.daggerheart.character_profile.delete | sys.daggerheart.character_profile_replaced, sys.daggerheart.character_profile_deleted | Daggerheart character profile projection and snapshot readiness state | Inline apply mode-controlled | Profile payload must remain structurally valid; delete only used for explicit reset/remove flows |
| Class feature activation/resolution | sys.daggerheart.class_feature.apply | sys.daggerheart.character_state_patched | Daggerheart character state (class_state, plus Hope/Armor when the feature spends or restores resources) | Inline apply mode-controlled | Feature must belong to the character’s primary class; typed payload must satisfy feature-specific costs and bounds; command must emit a meaningful state patch |
| Companion experience begin/return | sys.daggerheart.companion.experience.begin, sys.daggerheart.companion.return | sys.daggerheart.companion_experience_begun, sys.daggerheart.companion_returned, optional sys.daggerheart.character_state_patched | Daggerheart companion state and owner Stress on completed return | Inline apply mode-controlled | Character must own a companion sheet; begin requires a present companion and an owned experience ID; return requires an active assignment; completed return may clear owner Stress atomically |
| Beastform transform/drop | sys.daggerheart.beastform.transform, sys.daggerheart.beastform.drop | sys.daggerheart.beastform_transformed, sys.daggerheart.beastform_dropped | Daggerheart character state (class_state, plus Hope/Stress on transform) | Inline apply mode-controlled; damage follow-up may issue drop after damage_applied in the same request path | Transform resolves a replay-safe active beastform snapshot; drop clears only beastform state; auto-drop occurs on last HP marked and beastform-specific HP-mark triggers such as Fragile |
| Subclass feature activation/resolution | sys.daggerheart.subclass_feature.apply | zero or more sys.daggerheart.character_state_patched, sys.daggerheart.condition_changed, sys.daggerheart.damage_applied, sys.daggerheart.gm_fear_changed | Daggerheart subclass state plus any immediate owner/target consequences | Inline apply mode-controlled | Feature must belong to an unlocked subclass rank; typed payload must satisfy feature-specific costs, targets, and use limits; command must emit at least one meaningful consequence |
| Level-up progression | sys.daggerheart.level_up.apply | sys.daggerheart.level_up_applied | Daggerheart character profile (level, tier, advancements) | Inline apply mode-controlled | Level bounds valid; tier achievement at correct thresholds; advancement budget within level allowance |
| Gold/currency tracking | sys.daggerheart.gold.update | sys.daggerheart.gold_updated | Daggerheart character profile (gold denominations) | Inline apply mode-controlled | Denomination values non-negative; campaign/character valid |
| Domain card acquisition | sys.daggerheart.domain_card.acquire | sys.daggerheart.domain_card_acquired | Daggerheart character domain card vault/loadout | Inline apply mode-controlled | Card ID valid; destination (vault/loadout) specified; level-gating validated |
| Equipment swap | sys.daggerheart.equipment.swap | sys.daggerheart.equipment_swapped | Daggerheart character equipment slots | Inline apply mode-controlled | Item type valid (weapon/armor); slot tracking consistent; burden limits respected |
| Consumable use | sys.daggerheart.consumable.use | sys.daggerheart.consumable_used | Daggerheart character consumable inventory | Inline apply mode-controlled | Consumable exists with sufficient quantity; quantity bounds valid |
| Consumable acquisition | sys.daggerheart.consumable.acquire | sys.daggerheart.consumable_acquired | Daggerheart character consumable inventory | Inline apply mode-controlled | Stack max (5) not exceeded; consumable ID valid |
ApplyRollOutcome sequencing contract
ApplyRollOutcome must preserve this command order for replay-safe ownership:
- optional
sys.daggerheart.gm_fear.set - per-target optional
sys.daggerheart.character_state.patch - per-target optional
sys.daggerheart.condition.change - final
action.outcome.apply
Invariants:
action.outcome.applyis journal-facing and must not include system-owned effects inpre_effects/post_effects.- Daggerheart state mutation is expressed only through explicit
sys.daggerheart.*commands/events. - Session-side follow-up effects (for example gate open + spotlight set) remain core-owned post-effects on
action.outcome.apply.
Session-start bootstrap contract
When session.start activates a Daggerheart campaign from draft to active, the readiness workflow asks the active system module for optional bootstrap events before append.
For Daggerheart:
- readiness still blocks the start when any Daggerheart character fails
CharacterReady - after readiness passes, first-session bootstrap emits exactly one
sys.daggerheart.gm_fear_changed - the seeded value equals the number of created PCs in the campaign character set
- NPCs do not contribute to the seed
- later session starts emit no bootstrap event; existing Fear carries forward
This keeps initial Fear deterministic while avoiding snapshot-factory coupling to campaign character topology.
Known Gap: Consequence Atomicity
ApplyRollOutcome applies consequence commands sequentially. If command 3 of 5 fails, commands 1-2 are already persisted. This is acceptable because:
- Each consequence command independently produces valid state — there is no intermediate “half-applied” state that violates domain invariants.
- Idempotency guards prevent double-application on retry.
action.outcome.applyat the end serves as a completion marker — its absence signals that the consequence set is incomplete, enabling retry.- Replay recovers intermediate state deterministically from the event journal.
If true multi-command atomicity is needed in the future, follow the rest.take precedent: a single command whose decider emits multiple events from one decision, all batch-appended atomically.
Beastform damage follow-up ordering
When character damage forces a beastform drop, preserve this order:
sys.daggerheart.damage.applysys.daggerheart.damage_applied- optional
sys.daggerheart.character_state.patchfor armor-side follow-ups such asimpenetrable - optional
sys.daggerheart.beastform.drop - optional
sys.daggerheart.beastform_dropped
Invariants:
- Beastform auto-drop is a write-path follow-up, never projection-side mutation.
damage_appliedremains the authoritative HP mark event.- Beastform drop clears transformed attack/evasion state only; armor marks and ongoing spells remain untouched.
Mechanic tracking
Track unresolved mechanics directly in this timeline contract using explicit TBD command/event rows and clarification-gate notes until the contract is resolved.
Source Field Convention
CharacterStatePatchedPayload.Source is an optional discriminator set by transform commands that emit character_state_patched events. It enables journal queries to distinguish the origin of a patch without inspecting field patterns or introducing separate event types.
| Transform command | Source value |
|---|---|
sys.daggerheart.hope.spend | hope.spend |
sys.daggerheart.stress.spend | stress.spend |
sys.daggerheart.character_state.patch (direct) | (empty — generic GM/system adjustment) |
When adding new transforms that emit character_state_patched, set Source to the originating command’s short name (the suffix after sys.daggerheart.).
Design Principle: Prefer DecideFuncMulti for Multi-Consequence Atomicity
When a single mechanic produces multiple consequences (N damage events, a rest plus downtime events and countdown updates, etc.), prefer emitting all events from one command decision via DecideFuncMulti rather than executing sequential commands.
Why: A single command decision → batch append is atomic. Sequential commands are individually valid but can partially fail — command 3 of 5 succeeds while command 4 fails, leaving the journal in a state that requires retry logic. The rest.take precedent demonstrates the atomic pattern: one command whose decider emits rest_taken plus any downtime sub-events and optional countdown_advanced events, all batch-appended in a single call.
When to use:
- One mechanic naturally produces N events of the same type (e.g. multi-target damage → N ×
damage_applied). - One mechanic produces events of different types that must succeed or fail together (e.g. rest + countdown update).
- The consequence set is known at decision time (not discovered mid-sequence).
When sequential commands are acceptable:
ApplyRollOutcomeapplies consequences that are independently valid — each command produces valid state on its own, andaction.outcome.applyat the end serves as a completion marker. See “Known Gap: Consequence Atomicity” above for the full rationale.
Pattern:
// Atomic: one command, multiple events via DecideFuncMulti
func decideMultiTargetDamage(cmd command.Command, state *SnapshotState, now func() time.Time) command.Decision {
return module.DecideFuncMulti(cmd, state, func(targets MultiTargetPayload) ([]command.DecisionEvent, *command.Rejection) {
events := make([]command.DecisionEvent, 0, len(targets.Targets))
for _, t := range targets.Targets {
events = append(events, command.DecisionEvent{
Type: EventTypeDamageApplied,
// ... per-target payload ...
})
}
return events, nil
}, now)
}
Non-Negotiable Handler Rules
- Mutating request handlers must use shared orchestration (
executeAndApplyDomainCommand). - Request handlers must not call direct event append APIs.
- Request handlers must not call direct projection/storage mutation APIs for domain outcomes.
- Every mutating command path must reject empty decision events unless explicitly audit-only.
- Inline projection apply behavior must be controlled only by runtime mode policy.
Required Guard Tests
Use these tests as baseline architecture guardrails:
internal/services/game/api/grpc/systems/daggerheart/write_path_arch_test.gointernal/services/game/api/grpc/systems/daggerheart/workflowwrite/write_test.gointernal/services/game/api/grpc/game/domain_write_helper_test.go
When adding a new mutating mechanic, update/add tests so bypass patterns fail fast.
Timeline Row Template
Use this template when adding a new mechanic to the timeline table above. Fill in each column before writing implementation code.
Implemented mechanics (main table)
| <Mechanic name> | `sys.daggerheart.<domain>.<verb>` | `sys.daggerheart.<domain>_<verb_past>` | <projection store(s)> | Inline apply mode-controlled | <domain rules; validation constraints> |
Priority missing mechanics (P-table)
| P<N> | <Mechanic gap scenario> | `sys.daggerheart.<domain>.<verb>` | `sys.daggerheart.<domain>_<verb_past>` | <projection store(s)> | Inline apply mode-controlled | <domain rules; validation constraints> |
Column guidance
| Column | What to write |
|---|---|
| Mechanic | Short name describing the player/GM action (e.g., “Character damage apply”) |
| Command Type(s) | Dot-separated command types, comma-separated if multiple. Core commands use bare names (action.roll.resolve); system commands use sys.daggerheart.* namespace |
| Emitted Event Type(s) | Past-tense event types matching the commands. One command may emit N events via DecideFuncMulti |
| Projection Targets | Which stores are written: “Daggerheart snapshot”, “Character HP/armor”, “Event journal (no direct mutation)”, etc. |
| Apply Policy Notes | Typically “Inline apply mode-controlled”. Use “Journal-only apply path” for audit-only events. Note when core vs system events have different policies |
| Required Invariants | Semicolon-separated domain rules: validation checks, state preconditions, event emission guarantees |
Naming conventions
- Commands:
sys.daggerheart.<domain>.<verb>(e.g.,sys.daggerheart.damage.apply) - Events:
sys.daggerheart.<domain>_<verb_past>(e.g.,sys.daggerheart.damage_applied) - Core commands omit the
sys.daggerheart.prefix (e.g.,action.roll.resolve)
How To Add A New Daggerheart Mechanic
- Add command and event registrations in the Daggerheart decider/registry.
- Add a timeline row in this document before implementation.
- Implement request handler using shared write orchestration only.
- Implement/update adapter projection handling for emitted event types.
- Add Red/Green tests:
- command/event behavior
- projection/apply behavior
- architecture bypass guard where relevant
- Validate runtime mode behavior (
inline_apply_only,outbox_apply_only,shadow_only) is explicit for the new path.