事件模型:字符串类型 + JSON,只声明一次
上级:events
状态: 已在 v0.1.76 发布(2026-09-27)—— 设计与落地记录见 StandMeet 仓库的 docs/design/event-bus-outbox-webhooks.md。
事件 = 类型字符串 + JSON。底座没有“每种事件一个 Go 类型”,每种事件类型都由拥有它的域以数据形式只声明一次。
event = { id (uuid), seq, type (string), owner_id, subject (string URI or id),
occurred_at, data (json) }
id是gen_random_uuid()。seq是自增列,只用来给领取排序,没有游标读它(relay-claims-rows-not-cursor)。- outbox 行还带着 relay 的记账字段:
fanned_out_at、fanout(每个订阅方拿到了哪个任务)、relay_failures、poisoned_at、last_error。
类型命名
type是点分字符串:名词(本身可以带点)+ 动词。例:corpus.note.changed、access_request.created、microsite.build.settled。- 底座只认字符串和 JSON。这和 seam 是同一条规则:seam 是名字 + verb + JSON,从不是 typed 契约。
- 事件从哪来(触发器还是
Record)不体现在形状上:见 two-sources-of-events。
声明是数据
| 字段 | 含义 |
|---|---|
Type | 点分类型字符串 |
Description | 给人看的说明 |
Subject | 主体的模式,如 <genre>://<note path> 或 webhook://<endpoint id> |
Exposure | 谁能收到:Internal 或 Webhook |
- 各域声明自己的类型,经 facade 暴露;组合根在
cmd/server/wire/periodic.go里每个来源一行收集。 - 后台 UI 和 MCP 的
webhooks.event_types读的就是收集出的列表,没有手工维护的清单。 Record一个没声明的类型 → 返回ErrUndeclaredType,不是静默写进去。订阅的通配匹配不到任何已声明类型 → 启动失败。- 注册表 UT(
cmd/server/wire/event_types_test.go)逐条检查声明:只声明一次、点分的名词加动词命名、有描述、有主体模式、exposure 是有意选的。
Exposure 默认 Internal
| 取值 | 谁能收到 |
|---|---|
Internal (零值) | 只有进程内订阅方 |
Webhook | 还可以经 webhooks 出实例 |
新事件类型不会意外出实例。这回应了生成式界面的误暴露风险:忘了归类,绝不默认公开。类型停在零值却没被有意列为 internal,注册表 UT 就失败。
已声明的 39 个类型
全部 thin,全部 Webhook 可见,默认都不订阅。
| 来源 | 类型 |
|---|---|
| corpus(触发器) | corpus.note.changed |
| corpus(writings) | writing.published、writing.unpublished |
| owner:webhook | webhook.test |
| access | access_request.created、.approved、.status_changed;code.issued、.revoked、.redeemed;api_key.issued、.revoked |
| conversation | conversation.started、.message、.pruned;ghost.accepted |
| owner:预约 | booking.created、.cancelled、.rescheduled |
| owner:微站 | microsite.build.settled、page.promoted_live、.rolled_back、.unpublished、microsite.store.doc_inserted |
| owner:账户与 vault | owner.login、.email_changed、.recovery_requested;gas.exhausted、.refilled;vault.imported |
| stats | instance.upgrade_requested |
| security | ip_ban.added |
| block 与 supplier(block 模型) | block.installed、.failed;supplier.connected、.disconnected、.activated |
| 求职闭环 | application.committed、jobs.fetched |
instance.upgrade_requested在自己的事务里提交,而且只在 updater 存在时才记。owner.login在自己的事务里提交(登录不写行,会话在 Redis 里)。用恢复短语登录也会记它,和被用掉的短语一起提交。jobs.fetched每个抓取成功的源记一条。
payload:thin
payload 只带类型、主体和 id(Stripe thin events 的形状)。消费方回它本来就用的 API 取资源。出实例的数据最少,事实只在生产方一处。
核心类图
- 底座只认
type字符串和 JSON。 Exposure默认Internal,忘了归类的事件出不了实例。- embed 范围复用唯一的 ACL 判定
entity.AllowsCorpusEntry。组合根把 access 域的回答交给 owner 域的扇出,所以 infra 不 import 任何域。
相关:relay-claims-rows-not-cursor(行怎么变成任务)、queue-behind-ports(接口)、webhooks。