Core Event Contract¶
1) Design goals and non-goals¶
Goals¶
- Correctness first. Durable facts are never published before they are committed.
- One obvious write path. Call sites construct a domain event and hand it to one seam; no ad-hoc envelope stamping.
- Small abstractions. Keep interfaces minimal and fakeable in tests.
- Safe API boundaries. The important invariant — commit before dispatch — should be enforced by API shape, not caller discipline.
- Operational clarity. Each surface (log, bus, observers) has explicit semantics and failure behavior.
- Stable contracts. Event payloads are wire contracts, protected by typed constructors and golden tests.
- Low dependency footprint. Pure Go interfaces + stdlib primitives.
Non-goals¶
- Not a full event-sourcing framework.
- Not exactly-once delivery over the network.
- Not a cross-process message broker.
2) Principles¶
- Persist before publish. Durable events become visible on the live bus only after commit.
- Append-only log for facts. Durable event history is replayable and treated as an immutable journal.
- Separate facts from signals.
- Facts: durable domain history (mutation events, selected board events).
- Signals: ephemeral runtime hints/conditions (may be dropped; not replayed).
- The core records what happened; consumers decide what to do.
- Make the easy path the safe path. Constructors + seam stamping enforce actor/time/scope invariants by default.
3) Event value (wire compatible)¶
type Event struct {
ID int64 `json:"id"` // monotonic, assigned on append
Version int `json:"version,omitempty"` // event contract version; v1 default
Scope Scope `json:"scope,omitempty"` // "card" | "board"
CardID string `json:"card_id,omitempty"` // required when Scope==card
BoardID string `json:"board_id,omitempty"` // required when Scope==board
Type EventType `json:"type"`
Actor string `json:"actor"` // stamped by seam
At time.Time `json:"at"` // stamped by seam
Diff any `json:"diff"`
}
Invariants¶
- Event envelopes are created via constructors only:
func CardEvent(cardID string, t EventType, diff any) *Event
func BoardEvent(boardID string, t EventType, diff any) *Event
- Constructors set identity/scope/type/version/diff fields only.
ActorandAtare stamped by the seam (ActorFromCtx+ injected clock).Versiondefaults to1; additive payload fields can stay on the same version, while renames/removals/semantic changes require a new version.Diffremainsanyin the envelope to keep the wire model lightweight, but built-in event payloads use named Go structs and typed constructors.
4) Event contracts and compatibility¶
Events are integration contracts, not incidental log lines. The envelope remains small and stable; payload evolution is controlled per event type.
Rules:
- Built-in event diffs are represented by named Go structs, even though the
envelope field is
any. - Prefer event-specific constructors for common mutation events:
func StatusChanged(cardID string, before, after string) *Event
func OwnerChanged(cardID string, before, after string) *Event
func CommentAdded(cardID string, commentID string) *Event
- Raw
Event{...}literals are allowed only in constructors and tests. - Compatibility is protected with golden JSON fixtures: one fixture per public event type/version.
- Consumers must tolerate unknown fields. Producers must not rename, remove, or change the meaning of existing fields without introducing a new version.
This keeps Diff any pragmatic without letting payloads become undocumented
shapes.
5) Two lanes: facts vs signals¶
The system exposes two explicit write verbs:
// Durable fact: stamp -> persist -> dispatch
Emit(ctx context.Context, evs ...*Event) error
// Ephemeral signal: stamp -> dispatch (no persist)
Signal(ctx context.Context, evs ...*Event)
Rule of thumb¶
- If an event is needed for audit/recovery/catch-up, it is a fact (
Emit). - If an event is only a live runtime hint, it is a signal (
Signal).
Guardrail¶
When in doubt, choose fact. Durability can be ignored by consumers; absence cannot be recovered.
6) Core abstractions¶
| Abstraction | Responsibility | Kind |
|---|---|---|
Event |
one occurrence | struct |
EventLog |
durable append + query + replay | interface |
Bus |
best-effort live fanout | interface |
Emitter |
public seam for standalone facts/signals; owns internal stamp/dispatch | struct |
EventObserver |
in-process instrumentation hook | func |
6.1 EventLog [built]¶
type EventLog interface {
Append(ctx context.Context, evs ...*Event) error
List(ctx context.Context, q EventQuery) ([]Event, error)
Page(ctx context.Context, q EventQuery) (*Page[Event], error)
Replay(ctx context.Context, fromID int64, fn func(*Event) error) error
}
Notes:
- Card mutation events still persist transactionally with card writes for
atomicity.
- Standalone durable events use Append directly.
6.2 Bus [built]¶
type Bus interface {
Subscribe(filter EventFilter, buf int) *Subscriber
Unsubscribe(id int64)
Publish(e *Event)
}
Required behavior:
- Non-blocking publisher path.
- Slow subscriber policy is explicit (drop subscriber + metric/log marker).
- EventFilter must honor scope, card_id, and board_id consistently.
6.3 Emitter [built]¶
type Emitter struct {
log EventLog
bus Bus
now func() time.Time
observers []EventObserver
}
func (e *Emitter) Emit(ctx context.Context, evs ...*Event) error
func (e *Emitter) Signal(ctx context.Context, evs ...*Event)
// Internal/package-private helpers used only by transaction-aware service code:
func (e *Emitter) stamp(ctx context.Context, evs []*Event)
func (e *Emitter) dispatchCommitted(evs []*Event)
Contract:
- stamp is idempotent (only fills unset Actor/At).
- dispatchCommitted is post-commit only and is not exposed to arbitrary call
sites.
- Normal service code uses Emit, Signal, or a transaction-aware service
helper such as commitCard; it does not call stamp/dispatch directly.
- Call sites never assign ID, Actor, or At manually.
6.4 EventObserver [proposed]¶
Observer guidance: - Observers run synchronously during dispatch. - They must be fast and non-blocking. - Any I/O must be offloaded internally (goroutine/channel).
7) Emission lifecycle¶
Common lifecycle:
7.1 Transactional card mutations (facts)¶
func (s *Service) commitCard(ctx context.Context, next *Card, evs []*Event) error {
s.emitter.stamp(ctx, evs)
if err := s.store.UpdateCard(ctx, next, evs); err != nil { // atomic with event rows
return err
}
s.emitter.dispatchCommitted(evs)
return nil
}
7.2 Standalone facts¶
emitter.Emit(ctx, BoardEvent(...))
7.3 Signals¶
emitter.Signal(ctx, CardEvent(...)) for ephemeral conditions/notifications.
Hard invariant: no dispatch before durable commit for fact events. The API
should make the safe path the only normal path: dispatchCommitted remains
package-private, and card writes go through commitCard rather than open-coded
stamp/store/dispatch sequences.
8) Failure semantics (explicit)¶
- Persist fails (fact path): return error; dispatch does not run.
- Dispatch fails: bus/observer failures never roll back committed facts.
- Observer panic: recover per observer, report error metric/log, continue remaining observers (recommended implementation).
- Slow subscribers: dropped per bus policy; recovery via feed replay.
- Process crash after commit but before dispatch: durable correctness is preserved because the event is in the log, but live subscribers/observers may miss that event in the synchronous dispatch model. Consumers that need correctness recover through the feed.
- Escalated conditions are at-least-once across restarts. The crossing dedup that suppresses repeat condition emissions (e.g. the WIP exceeded/cleared state map) is in-memory. After a restart a condition that is still true re-fires on the next triggering mutation, appending a duplicate durable fact. Consumers must therefore treat escalated condition facts as idempotent assertions of a state — keyed by their identity (board + column + type), deduped by the consumer — not as counted occurrences. Temporal conditions (§12, Step 3d) avoid the duplicate entirely with a fired-marker reconstructible from denormalized state; instant conditions either adopt the same discipline or accept at-least-once by contract.
- Escalated-condition append failure.
Conditionroutes escalated types throughEmit; if that append fails, the durable audit trail — the whole reason the type was listed insettings.persist_conditions— silently gains a hole. Best-effort callers (e.g.evaluateWIP) must not fail the triggering mutation on it, but the seam must surface the append error (log / observer / metric), never swallow it. (A signalled condition dropping is by definition for nobody; an escalated one dropping is data loss.)
This keeps data integrity deterministic while making live delivery best-effort. If post-commit live/observer delivery must itself become reliable, evolve to the outbox/tailer model in rollout.md §12 Step 4.
8.7 State ownership: canonical card state vs SQLite-owned durable state¶
A workspace has two kinds of durable state, and they have different homes:
- Canonical card state — workspace/type/board definitions (
definitions/) and current card state (cards, their fields, links, comments). This is the git-portable form:cards export --state-onlywrites it as JSONL, andcards importreconstructs it into a fresh store. A committedbacklog.jsonlsnapshot is the source of truth for "what cards exist." - SQLite-owned durable state — the append-only event journal, the
condition_marksfired-markers, and any future cursor / dead-letter / subscription tables. These are ground truth that lives only in SQLite. They are not part of the canonical JSONL and an import never reconstructs them:cards importrefuses a non-empty workspace, so it is a fresh-clone restore of card state, never a merge that could truncate or rewrite history.
The one deliberate bridge: a --state-only export also carries card_deleted
tombstones (only those), so a reference to a since-retired card stays
resolvable from the snapshot without dragging in the churn of the full mutation
log. Everything else in the journal stays SQLite-owned.
Consequence: rebuilding a DB from a committed export restores card state and the record of deletions, but not event history or delivery/cursor state — those are re-accumulated live. Design any consumer accordingly.
9) Delivery semantics by surface¶
- Event log / feed (
Page,Replay): durable, ordered byid ASC, replayable. - Live bus / SSE: at-most-once best-effort for current subscribers.
- Observers: in-process hooks only; not durable.
Consumer correctness model:
- Track cursor (last_seen_id).
- Treat handlers as idempotent.
- Recover gaps via durable feed, then resume live.
Note: we avoid "exactly-once" claims at transport boundaries; practical correctness is achieved through durable cursors + idempotent consumers.
10) Testability model (first-class)¶
All of the fakes/fixtures below exist:
internal/core/eventlogtest(fake +Conformancesuite),internal/core/events_test.go(golden fixtures + raw-literal guard), and the store-side conformance run ininternal/sqlite/eventlog_conformance_test.go.
Required test seams:
- In-memory
EventLogfake for append/page/replay tests. - Recording
Busfake for publish order/filter/drop behavior. - Recorder observer for assertion-friendly capture.
- Injected clock for deterministic
Atvalues.
Minimum acceptance tests:
- stamp determinism (
Actor,At, idempotent stamp) - persist-before-dispatch invariant
- failed persist emits nothing
- monotonic IDs from store append order
- replay round trip reproduces durable stream
- bus filter correctness (
scope/card/board/type/actor) - subscriber drop behavior under full buffer
- observer panic isolation
- golden JSON compatibility for every public event type/version
- service mutation -> expected event table tests
Shift-left checks:
- Forbid raw
Event{...}literals outside constructors/tests. - Forbid manual assignment of
ID,Actor, orAtoutside the store/emitter. - Keep event constructors small and table-tested.
- Treat fixture changes as compatibility-affecting review items.
11) Event catalog (current and staged)¶
11.1 Durable mutation facts [built] (scope: card)¶
card_createdcard_deletedstatus_changedfield_updatedowner_changedtags_changeditem_appendeditem_updateditem_removedlink_addedlink_removedcomment_addedcomment_editedschema_upgradedartifact_added
(Per-type diff shapes remain as currently documented and wire-compatible.)
11.2 Condition signals¶
Examples: status_timeout [built, 3e], card_idle [built, 3e],
wip_exceeded [built, 3a], lane_drained [built, 3c],
transition_rejected [built].
Default to Signal; promote to durable fact only if recovery/audit use-cases
require replay.
Escalation (settings.persist_conditions) [built, 3b]. Condition events
are emitted through the single Emitter.Condition seam, which routes each
event by policy: a type listed in workspace settings.persist_conditions (e.g.
["wip_exceeded"]) goes through Emit (durable fact — appended to the log,
replayable from the feed and surviving a restart); every other condition type
goes through Signal (ephemeral). Bus and observer dispatch use the same path;
persisted events additionally receive a durable id and appear in the feed,
while ephemeral events have no replay cursor. This gives integrators an opt-in
audit/replay trail (each escalated event can become a durable system card on
their side) without making all conditions durable by default.
11.3 Board-scoped facts [built]¶
The event envelope includes scope + board_id, keeping card_id optional by
scope. Existing card-event consumers remain backward compatible.
11.4 Definition lifecycle signals [built]¶
definition_reloaded and definition_reload_failed are live, bus-only
notifications from POST /v1/workspace/reload and serve --watch; they are
not card mutations or durable feed entries. The reload path publishes them per
affected board with board_id set (a failure with no known board has no
board_id). A failed reload keeps the last-good definitions serving. See
docs/architecture/reload.md.