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).
| 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 }.
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" }
}
idempotency_key equals id. A resend of the same line is the same
occurrence; a receiver that keeps keys recognises it.correlation_id groups: a cycle and the beats recorded in it, a ceremony and
its closing, a conclusion and the person it is about.payload.changed. A write that changes
nothing writes no event.| 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.
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.
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:
MW_EVENT_ID, MW_EVENT_TYPE, MW_EVENT_SUBJECT_KIND, MW_EVENT_SUBJECT_ID,
MW_EVENT_CORRELATION and MW_EVENT_OBSERVED_AT go in the environment.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.
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):
topicFor(event, { scheme, naming }) divides events into topics in one of four schemes:
event: one topic per event type, so one schema per topic;family: one ordered topic per lifecycle;subject: one per subject kind;single: everything in one topic.Names follow the rules of nats, kafka or cloudflare.
partitionKey(event) is the correlation, else the subject. It keeps a cycle and its beats in order together.toCloudEvent / fromCloudEvent / cloudEventHeaders turn the envelope into CloudEvents 1.0, structured or
binary, and back, field for field.classifyHttpStatus is one rule for every HTTP destination:
rewind replays an event with the
same idempotency key.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: |
rewind is 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.
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.