medicine-wheel

@medicine-wheel/events

The events a Medicine Wheel writes when something happens on it: a cycle opened, a beat recorded, a ceremony closed, a circle created, a chronicle episode registered, a relation woven. One catalog, the envelope the events travel in, the files that describe them for any consumer, the log they wait in, and the seam a broker plugs into.

The wheel keeps each record’s latest state in its store. This package keeps the moments: every write the wheel commits leaves one line in events.jsonl beside the store. Nothing leaves the machine until a publisher is configured. Episode 548 of the Miadi Chronicle is choosing the broker (jgwill/medicine-wheel#164, miadisabelle/kherix-hermeneia#70).

For a consumer

file what it is
schema/wheel-events.schema.json One JSON Schema (draft-07) that validates any wheel event: the envelope, plus each event’s type, subject kind and payload.
schema/asyncapi.json An AsyncAPI 3.0 document: one channel per event, addressed by its topic (medicine-wheel.cycle.opened). AsyncAPI is to events what OpenAPI is to HTTP; AsyncAPI Studio renders it and generators read it. It names no server, because no broker is chosen.
const schema = require('@medicine-wheel/events/schema/wheel-events.schema.json');
const { WHEEL_EVENT_CATALOG, filterEvents } = require('@medicine-wheel/events');

A payload carries references and labels: ids, a direction, a type. A cycle’s question, a beat’s prose and a diary entry’s text stay in the wheel. To read one, fetch the record by subject.id from the wheel that wrote the event (source.instance).

There is one exception. When two chronicle episodes are related, the chronicle.lineage.* events carry the sentence that says why (payload.relation). That sentence is already committed to the episode’s episode.yaml, so the event repeats it rather than releasing it. These events say so with privacy: { classification: "chronicle", content_included: true }.

The envelope

jgwill/Miadi’s MiadiEventEnvelope 0.1.0 (rispecs/events/00-miadi-event-spine.rispec.md):

{
  "id": "mwev:1792019860666:4c509d0b",
  "schema_version": "0.1.0",
  "event_type": "medicine-wheel.cycle.opened",
  "source": { "kind": "miadi", "system": "medicine-wheel", "instance": "ilex:8040" },
  "subject": { "kind": "cycle", "id": "cycle-1792019860642-hzqvkk" },
  "occurred_at": "2026-10-14T23:17:40.666Z",
  "observed_at": "2026-10-14T23:17:40.666Z",
  "correlation_id": "cycle-1792019860642-hzqvkk",
  "idempotency_key": "mwev:1792019860666:4c509d0b",
  "privacy": { "classification": "internal", "content_included": false },
  "payload": { "current_direction": "east" }
}

The events

family events
cycle cycle.opened, cycle.amended, cycle.archived
beat beat.recorded, beat.amended, beat.witnessed
ceremony ceremony.opened, ceremony.closed, ceremony.amended, ceremony.removed
diary diary.written, diary.amended, diary.removed
circle circle.created, circle.amended, circle.removed
chronicle episode chronicle.episode.registered, chronicle.episode.amended, chronicle.episode.removed
memory conclusion memory.conclusion.recorded, memory.conclusion.judged, memory.conclusion.amended, memory.conclusion.removed
node node.created, node.amended, node.removed
chronicle lineage chronicle.lineage.made, chronicle.lineage.amended, chronicle.lineage.removed — a relation between two chronicle episodes, with the why
relation relation.made, relation.amended, relation.removed

WHEEL_EVENT_CATALOG says, for each: what happened, which write produces it, what its correlation groups, and every payload field. PayloadOf<'beat.recorded'> is its payload type.

For a producer

import { buildEvent, cycleWritten } from '@medicine-wheel/events';
import { appendEvent, eventLogPath } from '@medicine-wheel/events/log';

const input = cycleWritten(before, after, Object.keys(patch)); // null when nothing moved
if (input) appendEvent(eventLogPath(dataDir), buildEvent(input, { instance: process.env.MW_INSTANCE }));

The deciders (cycleWritten, beatWritten, beatWitnessed, ceremonyWritten, diaryWritten, nodeWritten, conclusionJudged, relationWritten and the *Removed ones) are shared by the wheel’s HTTP routes and the MCP server’s local store, so the same act leaves the same event whichever door it used.

Carried by git

Before any broker exists, the log can travel through the data repository it already lives in. This is not a publisher: nothing here sends one event and waits for a receipt.

import { commitEventLog, followEventLog } from '@medicine-wheel/events/git';

On the wheel’s side, commitEventLog commits events.jsonl and nothing else. Anything else that is changed or staged stays exactly as it was. It then pushes. It pushes only commits that touch the log alone. When the branch also holds someone’s own data commit, it holds the push and says why. A log the repository ignores is reported as an error, not committed by force.

In the wheel, set MW_EVENTS_GIT_SYNC=push (or commit). The wheel then commits:

mw events sync commits now. GET /api/events/git shows the last pass.

Anywhere else, clone the data repository, then follow it:

npx -p @medicine-wheel/events mw-events follow ~/mw-data --exec ./on-event.sh
npx -p @medicine-wheel/events mw-events follow ~/mw-data --every 60   # pull every minute, print new events

follow pulls, then hands each event this follower has not handled yet to --exec, oldest first:

The cursor lives in --state-dir, which defaults to ~/.local/state/medicine-wheel-events, never in the clone. Use --name to give two followers of one clone separate places.

Publishing: one port, many adapters

Publisher is the port: one event in, one outcome out (arrived, failed, refused), with the destination’s declared guarantees. Each adapter is that port for one destination (ports and adapters, Cockburn 2005).

import { cloudflareQueuesPublisher, drainLog, hookdeckPublisher, httpPublisher, rewind } from '@medicine-wheel/events/publish';

await drainLog(logFile, cloudflareQueuesPublisher({ accountId, queueId, token }));

What every broker needs (route.ts, plus the drain):

One adapter per destination (adapters.ts):

adapter request arrived when
httpPublisher({ url, token, format }) POST, as the envelope, cloudevents or cloudevents-binary 2xx
hookdeckPublisher({ sourceUrl }) POST to a Hookdeck source URL 2xx at the source
cloudflareQueuesPublisher({ accountId, queueId, token }) POST /accounts/{a}/queues/{q}/messages, { "body": … }, a “Queues Edit” token 2xx and success: true

Each adapter declares what it promises: acknowledgement, ordering, retention, replay, redelivery, dead letters and consumer position. NATS JetStream and Kafka need a client and a server. Their adapter contracts and test plans are in miadi-chronicle://548, foundations/broker-adapters/.

Episode 548 draws an event’s path as Named → Sent → Arrived Failed → Replayed:

In the wheel, each destination is configured by its own environment variables and gets its own cursor. One that is down never holds another back.

destination variables
any HTTP receiver MW_EVENTS_PUBLISH_URL, with _TOKEN, _NAME and _FORMAT
a Hookdeck source MW_EVENTS_HOOKDECK_SOURCE_URL, with _FORMAT
a Cloudflare queue MW_EVENTS_CF_ACCOUNT_ID, MW_EVENTS_CF_QUEUE_ID and MW_EVENTS_CF_API_TOKEN, with MW_EVENTS_CF_FORMAT and MW_EVENTS_CF_API_BASE

GET /api/events/publish and mw events status show each destination: its place, its receipts and its guarantees. mw events replay <event-id|all> --publisher <name> replays one.

Foundations

docs/foundations/event-driven-architecture.md covers the literature behind this package and maps each idea to the code: notification versus event-carried state, the log, the outbox, idempotent receivers, dead letters and AsyncAPI. It also covers why the logic lives in the wheel and Miadi consumes it.