BodySense Treatment Aggregate 与 Immutable Revisions

解释 BodySense 为什么把 Treatment 建模成一个长期存在、带 mutable lifecycle/current pointer 的用户级 Aggregate,同时把每个 TreatmentRevision 保持不可变。

#type / synthesis #status / growing #tech / architecture #tech / dev / backend #tech / ai #resource / bodysense

[!info] related notes

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。

自测题

  1. 为什么 Treatment 不适合只有一串互相独立的 immutable plan?
  2. Mutable Aggregate + Immutable Revision 分别解决什么?
  3. Aggregate Root 的价值为什么不是“有一个父表”?
  4. AcceptanceState 与 LifecycleState 为什么是正交维度?
  5. Proposal 阶段为什么不应该立刻创建 durable Intervention?
  6. Rejected 与 Superseded 有什么本质区别?
  7. Acceptance transaction 为什么要同时维护 current pointer?
  8. Outcome 为什么必须能追到 exact Revision?
  9. 这个模式和 Event Sourcing 有什么区别?
  10. 为什么 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。

创建于 2026/8/22 更新于 2026/8/23