事件模型:字符串类型 + JSON,只声明一次
状态: 已在 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 取资源。出实例的数据最少,事实只在生产方一处。
核心类图
classDiagram
direction LR
class Event {
+uuid id
+bigint seq
+string type
+uuid owner_id
+string subject
+time occurred_at
+json data
+time fanned_out_at
+time poisoned_at
}
class EventType {
«declaration»
+string Type
+string Description
+string Subject
+Exposure Exposure
}
class Exposure {
«enumeration»
Internal ← zero value
Webhook
}
class Recorder {
«infra/events»
+With(tx) Recorder
+Record(ctx, ownerID, type, subject, data)
}
class RowTrigger {
«postgres trigger»
corpus_notes AFTER I/D, U WHEN changed
}
class Relay {
«loop in every process»
+FanOut(ctx) «SKIP LOCKED, ≤ 200 rows»
}
class Subscription {
«data»
+string Name
+string[] Types «glob»
+bool Coalesce
+Handle(ctx, Event) error «idempotent»
}
class WebhookFanout {
«subscription · owner domain»
globs, scope, one delivery per endpoint
}
class WebhookEndpoint {
+uuid id
+string url
+string[] event_types
+secret_enc
+uuid embed_id?
+bool enabled
+time failing_since
+time busy_until
}
class Delivery {
«webhook.deliver job»
+uuid endpoint_id
+uuid event_id
}
class EmbedAdmits {
«access domain, handed in»
AllowsCorpusEntry(scope, entry)
}
class Embed
EventType "1" --> "*" Event : types
EventType --> Exposure
Recorder ..> Event : writes in caller tx
RowTrigger ..> Event : writes in same tx
Relay --> Event : claims rows with fanned_out_at null
Relay ..> Subscription : one job per match
WebhookFanout --|> Subscription
WebhookFanout ..> EmbedAdmits : embed-attached endpoints
WebhookFanout ..> Delivery : enqueues, unique by args
Delivery --> WebhookEndpoint
WebhookEndpoint "0..1" --> "1" Embed : scope from its code
- 底座只认
type字符串和 JSON。 Exposure默认Internal,忘了归类的事件出不了实例。- embed 范围复用唯一的 ACL 判定
entity.AllowsCorpusEntry。组合根把 access 域的回答交给 owner 域的扇出,所以 infra 不 import 任何域。
相关:relay-claims-rows-not-cursor(行怎么变成任务)、queue-behind-ports(接口)、webhooks。