BodySense Treatment Aggregate 与 Immutable Revisions
解释 BodySense 为什么把 Treatment 建模成一个长期存在、带 mutable lifecycle/current pointer 的用户级 Aggregate,同时把每个 TreatmentRevision 保持不可变。
[!info] related notes
- 所属 MOC: bodysense-moc
- 相关概念: bodysense-diagnosis-durable-domain-model、treatment-proposal-acceptance-execution-authority
- 易混淆概念: Immutable history vs mutable current projection
- 相关资源: bodysense-treatment-vertical-slice
BodySense Treatment Aggregate 与 Immutable Revisions
一句话定义
BodySense 的 Treatment 不是“每次生成一个互相独立的方案对象”,而是:
一个长期存在的用户级 Treatment Aggregate,负责当前生命周期和 current revision;每一次具体方案则保存为 immutable TreatmentRevision。
它同时满足两个需求:
历史必须不可篡改
+
产品必须知道当前哪个方案正在生效
为什么这个模型不是“为了 DDD 而 DDD”
它来自一个真实业务矛盾:
历史版本必须保持原样
但同时:
系统又必须快速知道现在执行哪个方案
如果只保存一个 mutable Treatment JSON:
今天生成 R1
明天直接覆盖成 R2
历史消失。
如果只保存一堆 immutable plans:
R1 R2 R3 R4
每个读请求都要猜:
哪个 current?
哪个 accepted?
哪个 superseded?
所以才形成:
Mutable Aggregate Pointer/Lifecycle
+
Immutable Revision History
为什么不能直接复用 DiagnosisAnalysis 的建模方式
Diagnosis 很适合:
D100 immutable
D101 immutable
D102 immutable
每个 Analysis 都是一次独立的历史判断。
如果 Treatment 也完全这样:
P1
P2
P3
系统会很难直接回答:
- 用户当前到底执行哪个方案?
- 当前方案是 active、paused 还是 review recommended?
- 新 Proposal 是替代当前方案,还是仅供审核?
- 哪个版本已经被接受?
- 当前 Intervention 属于哪个方案版本?
因此 Treatment 需要一个稳定的长期身份。
Diagnosis 与 Treatment 的生命周期差异
可以对比:
DiagnosisAnalysis
= 一次时间点上的解释
→ 新状态产生新 Analysis
Treatment
= 一个持续存在的行动策略生命周期
→ 内部不断出现新 Revision
所以:
Diagnosis history = many immutable roots
Treatment history = one long-lived root + immutable revisions
这不是技术栈差异,而是业务时间结构不同。
两层模型
Treatment Aggregate
当前 Go 模型把 Treatment 定义为:
one current user-scoped intervention strategy aggregate
可以理解为:
Treatment T1
├─ UserID
├─ CurrentRevision
├─ Status
├─ SourceBodyStateRevision
├─ SourceDiagnosisAnalysisID
└─ StatusReasons
其中:
CurrentRevision
Status
StatusReasons
属于会随业务生命周期变化的当前态。
TreatmentRevision
每个具体方案版本是 immutable:
TreatmentRevision R3
├─ TreatmentID
├─ Revision
├─ AcceptanceState
├─ LifecycleState
├─ SourceBodyStateRevision
├─ SourceDiagnosisAnalysisID
├─ Goal / Duration / Plan
├─ UserConstraints
├─ EvidenceIDs
├─ AgentConfigurationID
├─ ExecutionProvenance
├─ EvidenceAcquisitionTrace
├─ GenerationDecisionTrace
├─ AcceptanceDecisionTrace
├─ ReplayInput
└─ Interventions
它回答:
“这个具体历史版本当时到底是什么?”
Aggregate Root 真正拥有的 Invariants
Treatment Aggregate 不只是“有一个父 ID”。它应该协调诸如:
最多一个 current accepted revision
current_revision 必须指向自己的一份合法 revision
accepted revision 不能被原地修改
supersede 时历史关系保持一致
Intervention 必须来自 accepted revision
status transition 必须合法
这才是 Aggregate Root 的价值:集中维护跨对象一致性。
如果只是:
Treatment table + revision table
但任何 service 都能随意更新 current pointer,就没有真正的 aggregate boundary。
Mutable Aggregate + Immutable Revision 解决了什么
问题 1:历史审计
如果用户今天接受 R3,明天系统重新生成 R4:
R3 accepted
R4 proposed
不能把 R3 的 plan 原地覆盖成 R4。
否则:
- 过去的 Intervention 来源消失;
- Outcome 无法解释;
- replay 不再可靠;
- 用户历史记录变成“今天视角重写昨天”。
问题 2:当前业务状态
但产品又不能只保存一堆 revisions,然后每个页面都重新推断:
“哪个才是现在正在用的?”
Aggregate 提供显式 current pointer:
Treatment T1
CurrentRevision = 3
Status = active
因此:
Immutable History
+
Mutable Current Projection
可以同时存在。
Current Pointer 是业务真值,不只是缓存
如果 CurrentRevision 只是一个可随时重建的性能缓存,那么丢失它还可以重新算。
但在 Treatment 里:
哪个 revision 当前生效
本身是业务决策结果。
所以它必须:
- 和 acceptance transaction 一致;
- 有唯一性 invariant;
- 不允许 read path 自己随意推断并修改。
AcceptanceState 与 LifecycleState 是不同维度
不要把:
proposed / accepted / rejected
和:
active / review_recommended / paused / superseded / completed
混在同一个 status 中。
AcceptanceState
回答:
这个 revision 有没有通过接受边界?
proposed
accepted
rejected
Aggregate / Lifecycle State
回答:
当前 Treatment 在长期执行过程中处于什么状态?
active
review_recommended
paused
superseded
completed
这两个轴可能组合出不同语义。
例如:
Revision R4 = proposed
Current Treatment = active on R3
表示:
R4 只是候选替代方案;在它被接受之前,R3 仍是 current。
为什么一个 status 字段很快会失控
如果把所有状态都塞成:
status = proposed | accepted | active | paused | rejected | superseded
会出现语义冲突:
accepted 是“是否通过 acceptance”
active 是“当前生命周期状态”
一个 revision 可能:
accepted + later superseded
如果只有一个枚举,很难表达“它历史上确实被接受过,但现在不再 current”。
这就是 orthogonal state dimensions 的价值。
为什么 Revision 需要 pin BodyState 与 Diagnosis
每个 TreatmentRevision 不是凭空产生的,应绑定:
SourceBodyStateRevision
SourceDiagnosisAnalysisID
这样可以形成历史链:
BodyState R70
↓
Diagnosis D30
↓
TreatmentRevision TR5
↓
Intervention I9
↓
Outcome O12
这条链允许未来回答:
“O12 对应的是哪个计划?这个计划又是基于哪次 Diagnosis 和哪版 BodyState?”
这正是 longitudinal system 的价值。
Revision Number 与 Revision Identity
可以有:
TreatmentID = T1
RevisionNumber = 3
但历史引用最好仍能稳定定位:
(T1, 3)
或独立 TreatmentRevisionID。
不要只存:
current_revision = 3
而没有确保 3 确实属于 T1。
Intervention 为什么不能只塞在 plan JSON 里
Proposal 阶段,Intervention 可以只是:
TreatmentInterventionDraft
但接受以后,真正可执行的 action unit 需要 durable identity:
Intervention
├─ ID
├─ TreatmentID
├─ TreatmentRevisionID
├─ Kind
├─ Prescription
├─ Status
├─ StartedAt
└─ EndedAt
因为后续:
TrainingLog → InterventionID
Outcome → InterventionID
都需要稳定引用。
[!important] 领域身份原则 只有未来需要被独立引用、执行、反馈和审计的 action,才值得 materialize 成 durable Intervention。
为什么 Proposal 阶段不急着 materialize Intervention
如果每个 AI draft 都创建 durable Intervention:
R4 proposed
→ I30/I31 created
但用户拒绝 R4,数据库里就出现一堆“从未被批准执行”的 action identity。
这会让下游更容易误引用。
更清晰:
Draft Intervention
→ Revision accepted
→ materialize durable Intervention
这样 Intervention 的存在本身就带有更强业务语义。
为什么旧 Revision 不应该随着 current review 被修改
假设:
R3 accepted when BodyState = 70
后来 Outcome 让 Treatment 进入:
review_recommended
错误做法:
修改 R3.plan
修改 R3.warning_signs
把 R3 改成“后来我们认为更合理”的内容
正确做法:
R3 remains immutable history
Treatment aggregate status = review_recommended
↓
if new plan needed
create R4
这延续了 Diagnosis 的核心原则:
Historical artifact
≠
Current applicability / current lifecycle
为什么“修正错字”也要小心 Immutable Revision
如果 R3 已经参与:
用户 acceptance
Intervention execution
Outcome
即使只是修 wording,也可能改变未来审计看到的内容。
可以考虑:
- presentation-only derived view;
- separate correction metadata;
- 新 revision。
不要直接更新原历史字段而不留痕。
Supersede 是新的历史关系,不是覆盖
当 R4 被接受并替代 R3:
R3 remains historical accepted revision
R4 becomes current
Treatment.CurrentRevision = 4
可以把旧版本表达成 superseded,但不要删除。
因为旧 Outcome 和 Intervention 仍然需要引用 R3。
Supersede 与 Reject 不一样
Rejected
= 从未通过 acceptance
Superseded
= 曾经是有效/当前历史,但后来被新 revision 替代
这两个语义对 audit、用户历史和 Outcome attribution 完全不同。
Acceptance Transaction 应维护哪些 Invariant
一个理想 transaction 可能包含:
check proposal still proposed
check current BodyState/revision guard
persist AcceptanceDecisionTrace
mark R4 accepted
switch CurrentRevision R3→R4
mark R3 superseded if applicable
materialize R4 Interventions
commit
任何一步失败都应 rollback。
否则会出现:
R4 accepted but not current
or
current points R4 but Interventions missing
与 Event Sourcing 的区别
这个模型带有 append-only history 的味道,但不等于完整 Event Sourcing。
这里的核心是:
TreatmentRevision = immutable state snapshot/artifact
Treatment = current aggregate projection
而不是要求所有 current state 都必须从 domain events 重放出来。
不要因为“有版本历史”就自动把架构叫 Event Sourcing。
与 MVCC / Git 的类比
可以用 Git 帮助理解:
Treatment = branch/current pointer
TreatmentRevision = immutable commit
新 revision 不会改旧 commit;current pointer 可以向前移动。
但这个类比有限:Treatment acceptance 还有业务 authority、安全与 Outcome,不只是版本控制。
一个纵向例子
Treatment T1
TR1 proposed
→ rejected
TR2 proposed
→ accepted
→ Interventions I1, I2
→ current_revision = 2
→ status = active
Outcome O5 linked to I1
→ BodyState R80
→ review says material change
→ Treatment status = review_recommended
TR3 proposed from R80 + new Diagnosis
→ accepted
→ current_revision = 3
→ TR2 remains immutable history
在这个例子里:
- T1 是长期业务身份;
- TR1/TR2/TR3 是历史方案版本;
- I1/I2 是被接受后可引用的 action units;
- O5 是现实反馈;
status表达当前生命周期,而不是篡改旧 revision。
与 Diagnosis Domain Model 的迁移关系
已经掌握的 Diagnosis 原则:
immutable historical artifact
pinned source identity
Go-owned durable IDs
past knowledge != current knowledge
在 Treatment 中全部继续成立。
真正新增的是:
immutable revisions
+
mutable current aggregate
+
long-lived action lifecycle
所以 L2 不需要重新学习“为什么历史要 immutable”,只需要理解:
为什么一个 action-oriented domain 还需要一个显式 current aggregate。
与 Outcome 的关系
Outcome 必须指向明确 Intervention,而 Intervention 又指向 exact Revision:
Outcome O5
→ Intervention I1
→ TreatmentRevision TR2
→ Treatment T1
这样 current pointer 已经变到 TR3 时,O5 仍然解释清楚。
如果只记录:
Outcome → Treatment T1
就无法知道当时执行的是哪个 prescription。
Read Model 为什么可以“扁平”,Write Model 不能因此扁平
UI 可能只需要:
current plan
current interventions
latest review status
可以构建扁平 read projection。
但 write/domain model 仍保留:
Aggregate + Revisions + Interventions + Outcomes
不要因为前端只展示一页,就把 durable history 也压成一个 JSON。
测试 Aggregate / Revision 应覆盖什么
Single current invariant
one Treatment → at most one current revision
Historical immutability
accept R3
later create R4
→ R3 content unchanged
Reject vs Supersede
两种状态语义不能混淆。
Intervention materialization
proposed revision → no durable action units
accepted revision → durable Interventions
Transaction rollback
acceptance 中任一步失败:
pointer/status/interventions/trace all unchanged
Outcome attribution
O5 → exact I1 → exact TR2
Read purity
GET/current projection 不应偷偷更改 aggregate lifecycle。
自测题
- 为什么 Treatment 不适合只有一串互相独立的 immutable plan?
- Mutable Aggregate + Immutable Revision 分别解决什么?
- Aggregate Root 的价值为什么不是“有一个父表”?
- AcceptanceState 与 LifecycleState 为什么是正交维度?
- Proposal 阶段为什么不应该立刻创建 durable Intervention?
- Rejected 与 Superseded 有什么本质区别?
- Acceptance transaction 为什么要同时维护 current pointer?
- Outcome 为什么必须能追到 exact Revision?
- 这个模式和 Event Sourcing 有什么区别?
- 为什么 read model 可以扁平但 domain history 不能因此被覆盖?
最终心智模型
Treatment
= long-lived current business identity
TreatmentRevision
= immutable historical plan artifact
Intervention
= durable executable unit materialized from accepted revision
Outcome
= durable observation about what happened afterward
这四个对象的生命周期不同,所以不能为了“简单”塞进一个万能 Treatment JSON。