docs: add HITL IM docs

This commit is contained in:
QuantumGhost
2026-07-11 17:02:34 +08:00
parent abd720146d
commit 196287fc8a
8 changed files with 2274 additions and 0 deletions
@@ -0,0 +1,369 @@
# HITL Approval Domain, Contact And IM Domain Model
## 背景
HITL 二期需要在 CE、EE、SaaS 三种部署形态下统一处理联系人、Human Roster、IM 绑定、IM 凭据和运行时审批人解析。
核心张力来自企业边界不同:
| 形态 | 物理部署 | 真实企业边界 | Workspace 语义 |
| --- | --- | --- | --- |
| SaaS | 多个企业共用一个 Dify 部署 | 一个 `Tenant` / workspace 通常就是一个 SMB 企业 | 企业本身 |
| CE | 一个部署对应一个企业 | deployment | 单 workspace facade |
| EE | 一个部署对应一个企业 | deployment | 企业内部门、团队或业务单元 |
因此不要让运行时直接判断 CE / EE / SaaS。领域层应先抽象出稳定的业务边界,再用策略对象处理部署形态差异。
## 直接结论
先引入 `ApprovalDomain` 作为产品层和应用层主抽象。它表示一次 HITL 配置与运行所属的审批域,用来统一回答:
- 当前 workflow 可以从哪个联系人目录和 Human Roster 选择审批人。
- 其他 workspace 的 Dify member 是否在当前审批域内可见。
- External contact 由谁管理,workflow editor 是否能创建。
- IM identity 和 IM app credential 应如何解析。
- 提交时如何校验身份、当前成员状态和 binding 状态。
- 审计时按哪个组织边界解释“谁审批了什么”。
`workspace_id (tenant_id)` 只应作为系统入口 locatorcontroller、现有 DB 存储、迁移和 adapter 可以使用它;进入应用服务后,应先解析为 `ApprovalDomainContext`,后续领域接口优先传 `ApprovalDomainContext``ContactDirectoryId``HumanRosterId``HITLTaskId` 等业务对象,而不是继续层层传 `workspace_id`
当前 SaaS 没有“一个企业多个 workspace”的产品计划,因此不建议现在新建 `approval_domains` 表。`ApprovalDomain` 可以先由 `ApprovalDomainResolver` 从 workspace locator 和部署形态推导出来;如果未来 SaaS 需要一个企业多个 workspace,再将 `ApprovalDomain` 或其背后的企业边界持久化。
这样保留产品语言的一致性,同时避免把 CE / EE / SaaS 差异泄漏到 Contact、Roster、Recipient、Task 的核心模型中。
## 领域划分
| Bounded Context | 职责 | 不负责 |
| --- | --- | --- |
| Identity Context | 对齐 `Account``TenantAccountJoin`、账号状态和 workspace 成员关系 | 发送通知 |
| Approval Domain Context | 解析当前审批域、联系人目录、Human Roster、IM 集成策略和授权策略 | 发送通知 |
| Contact Directory Context | 管理审批域内可见的 Dify account-backed contact 和 IM identity binding | HITL task 生命周期 |
| Roster Context | 管理当前审批域下可选择的联系人集合、external contact、成员离开后的可选择状态 | IM 凭据存储 |
| IM Integration Context | 管理 IM provider、app installation、credential owner、IM identity binding、通讯录同步 | 表单提交 |
| HITL Runtime Context | 从节点配置解析 recipients,创建 task 快照,发送通知,校验提交人 | 管理联系人目录 |
| Audit Context | 记录解析、投递、访问、提交、拒绝和敏感数据处理策略 | 决定业务权限 |
## 核心对象
### Approval Domain
| 对象 | 类型 | 说明 |
| --- | --- | --- |
| `ApprovalDomain` | Product Concept | 审批域。产品和应用服务讨论“谁可被通知、谁可提交、用哪套集成能力”的主语。 |
| `ApprovalDomainContext` | Application Context | 由 resolver 生成的运行时上下文,聚合联系人目录、Human Roster、IM 集成和授权策略。 |
| `ContactDirectoryId` | Value Object | 当前审批域可见的联系人目录引用。 |
| `HumanRosterId` | Value Object | 当前审批域下 workflow editor 可选择的联系人集合引用。 |
| `IMIntegrationPolicy` | Value Object | 当前审批域解析 IM provider、installation、credential chain 和 channel capability 的策略。 |
| `RosterPolicy` | Value Object | 当前审批域的 roster 初始化、添加、可选择和成员离开处理规则。 |
| `AuthorizationPolicy` | Value Object | 当前审批域打开、提交和 pending task 再校验规则。 |
| `AuditPolicy` | Value Object | 当前审批域审计字段、脱敏和保留策略。 |
`ApprovalDomainContext` 是代码中主要传递的上下文。它可以包含底层 owner / locator,但这些细节不应扩散到普通领域服务。
推荐形态:
```text
ApprovalDomainContext
id
contact_directory_id
human_roster_id
im_integration_policy
roster_policy
authorization_policy
audit_policy
```
内部实现仍然需要知道当前目录或凭据来自 workspace、deployment、ISV application 还是 tenant self-built app。但这些应封装在 resolver、repository adapter 或 policy 内部,避免让业务代码到处传底层 owner / locator。
当前映射建议:
| 形态 | `ApprovalDomain` 入口 | Contact Directory owner | Human Roster owner | IM credential policy |
| --- | --- | --- | --- | --- |
| SaaS | 当前 `Tenant` / workspace | 当前 `Tenant` | 当前 `Tenant` | Slack ISV workspace installation 或 tenant self-built app |
| CE | 默认 workspace facade | deployment | 默认 `Tenant` | deployment 或 default workspace facade |
| EE | 当前 workspace 的审批域 | deployment | 当前 `Tenant` | 默认 deployment-level,未来可 workspace override |
### Contact Directory
| 对象 | 类型 | 聚合边界 | 关键字段 |
| --- | --- | --- | --- |
| `ContactDirectory` | Aggregate Root | `ApprovalDomain` | `id``owner_ref``visibility_policy` |
| `ContactIdentity` | Entity | `ContactDirectory` | `id``directory_id``kind``account_id``external_contact_id``normalized_email``display_name``status` |
| `ExternalContact` | Entity | `HumanRoster` | `id``roster_id``normalized_email``display_name``status` |
| `IMIdentityBinding` | Entity | `ContactIdentity` | `id``contact_identity_id``provider``provider_user_id``binding_owner_ref``status``verified_at` |
建模原则:
- 只要能匹配到 Dify `Account`,就必须建模为 account-backed `ContactIdentity`,不能建模为 `ExternalContact`
- 其他 workspace 的 member 仍是 Dify member,只是当前审批域里“不属于当前 roster owner”的 account-backed contact。
- `ExternalContact` 只表示不属于 Dify 系统内 `Account` 的外部人员,并且只属于当前 Human Roster。
- Workflow editor 不能创建 `ExternalContact`;编辑节点时只能选择已有 roster entry 或输入 one-time email。
- 当前产品只允许一个联系人绑定一个 IM 身份,但数据模型不应假设永远只有一个 provider;唯一约束应放在 active binding 的策略上。
### Human Roster
| 对象 | 类型 | 聚合边界 | 关键字段 |
| --- | --- | --- | --- |
| `HumanRoster` | Aggregate Root | `ApprovalDomain` | `id``owner_ref``policy` |
| `RosterEntry` | Entity | `HumanRoster` | `id``roster_id``contact_identity_id``entry_kind``status``joined_at``snapshot` |
`RosterEntry.id` 是 HITL 节点静态 recipient 应保存的稳定引用。产品和 API 可以称为 `contact_id`,但领域语义上它是“当前 Human Roster 中的可选择联系人”,不是全局人。
`entry_kind` 是面向当前 Human Roster 的视图:
| `entry_kind` | 语义 |
| --- | --- |
| `workspace_member` | `Account` 当前属于该 Human Roster 的 owner workspace |
| `dify_member` | `Account` 属于当前审批域的 Contact Directory,但不属于当前 roster owner |
| `external_contact` | 当前 Human Roster 下的外部联系人 |
`status` 至少需要区分:
| `status` | 是否可被新节点选择 | 说明 |
| --- | --- | --- |
| `active` | 是 | 可通知、可审批 |
| `left_workspace` | 否 | 曾是 workspace member,已离开但保留历史引用 |
| `account_disabled` | 否 | 账号被禁用或删除 |
| `removed` | 否 | 被管理员从 roster 移除 |
| `pending_review` | 否 | 需要管理员处理,例如成员离开后选择移除或转 external |
### IM Integration
| 对象 | 类型 | 说明 |
| --- | --- | --- |
| `IMProvider` | Value Object | Slack、DingTalk、Lark、Teams、WeCom、Email |
| `IMProviderApplication` | Aggregate Root | IM app registration。SaaS ISV 的 `app_id` / `app_secret` 属于部署者;企业自建 app 属于 workspace 或 enterprise。 |
| `IMInstallation` | Aggregate Root | 某个 provider 在某个 owner 下的可用安装。运行时从这里得到可发送能力和 credential handle。 |
| `IMCredentialHandle` | Value Object | Secret store 中的引用,不直接暴露 secret。 |
| `IMChannelCapability` | Value Object | 是否支持卡片审批、fallback URL、平台身份获取、通讯录同步等。 |
IM identity 一期通过 directory sync / search 选择 provider user,不支持让管理员直接手动输入 provider user id。这个决定影响产品交互和 provider gateway 能力要求,但不改变 `IMIdentityBinding` 的领域模型。
凭据解析优先级由 `IMIntegrationPolicy` 给出,运行时只调用 resolver,不直接判断 owner 类型。默认顺序为:
1. `workspace` / `tenant` scoped installation。
2. `enterprise` / `deployment` scoped installation。
3. no IM installation,降级到 Email。
不同形态只影响 installation 的来源:
| 场景 | `IMProviderApplication` owner | `IMInstallation` owner |
| --- | --- | --- |
| SaaS Slack ISV | deployment operator | workspace OAuth installation |
| SaaS DingTalk self-built | workspace admin | workspace |
| CE | deployment operator 或 default workspace admin | deployment 或 workspace facade |
| EE | enterprise admin | deployment / enterprise,本期默认 deployment |
### HITL Runtime
| 对象 | 类型 | 现有模型映射 | 说明 |
| --- | --- | --- | --- |
| `NotificationPolicy` | Value Object | `HumanInputNodeData` extension | 节点配置中的通知策略。 |
| `RecipientSelector` | Value Object | node DSL | 静态 roster contact、one-time email、email variable、current initiator。 |
| `ResolvedRecipient` | Value Object | runtime only | 解析后的通知与审批对象,进入 task 前仍可去重。 |
| `AllowedApprover` | Value Object | `HumanInputFormRecipient` snapshot extension | 提交权限的 canonical identity。 |
| `HITLTask` | Aggregate Root | `HumanInputForm` | 一个等待人工输入的持久化 task。 |
| `TaskRecipientSnapshot` | Entity | `HumanInputFormRecipient` | task 创建时的 recipient、contact、email、IM identity、auth requirement 快照。 |
| `DeliveryAttempt` | Entity | `HumanInputDelivery` / future delivery record | 每个 recipient 每个 channel 的投递记录。 |
| `RecipientResolutionRecord` | Entity | future runtime log | 记录 dynamic email 等解析过程。 |
`HumanInputFormRecipient` 不应升级为 Contact。它是运行时 task 下的 recipient snapshot / access token holder。
## 关键不变量
1. `ExternalContact` 创建前必须用 normalized email 查找可见 `Account`;命中时只能添加 Dify member。
2. HITL 节点静态 recipient 只能引用当前 `ApprovalDomainContext.human_roster_id` 下 selectable 的 `RosterEntry`
3. one-time email 和 dynamic email 不写入 Human Roster。
4. task 创建后,`TaskRecipientSnapshot` 不可变,用于审计和历史显示。
5. 新 task 使用最新 contact、IM binding、credential 和 channel capability。
6. pending task 提交时必须重新校验安全敏感状态:account disabled、left workspace、removed external contact、deleted IM binding、IM identity mismatch。
7. 同一个 Dify `Account` 只能生成一个 `AllowedApprover`
8. 同一个 normalized email 只能生成一个 email recipient;如果能匹配 roster contact,应优先转成更强身份的 contact recipient。
9. IM + Email 双渠道触达是 delivery 策略,不代表两个 approver。
10. Secret 不进入 audit、delivery record、task snapshot,只保存 credential handle 和 provider metadata。
11. `workspace_id` 是入口 locator,不是领域服务的默认上下文;进入应用服务后应优先传 `ApprovalDomainContext`
## 策略接口
以下接口是领域层与部署形态差异之间的边界。实现可以在 CE / EE / SaaS 中不同,但调用方使用同一组 port。
```python
class ApprovalDomainResolver(Protocol):
# Resolves the product-level approval context from a system boundary locator.
# workspace_id should normally stop here and not leak into downstream services.
def resolve_for_workspace(self, workspace_id: WorkspaceId) -> ApprovalDomainContext: ...
# Rebuilds the current approval context for submission, retry, audit, or callback flows.
def resolve_for_task(self, task_id: HITLTaskId) -> ApprovalDomainContext: ...
class ContactDirectoryRepository(Protocol):
# Persists person identities visible in one approval domain.
def get_by_id(self, contact_identity_id: ContactIdentityId) -> ContactIdentity | None: ...
# Finds the contact identity backed by a Dify Account inside a contact directory.
def find_account_contact(self, directory_id: ContactDirectoryId, account_id: AccountId) -> ContactIdentity | None: ...
# Used to prevent creating ExternalContact when the email belongs to a Dify Account.
def find_by_normalized_email(self, directory_id: ContactDirectoryId, email: NormalizedEmail) -> ContactIdentity | None: ...
def save(self, contact: ContactIdentity) -> None: ...
class HumanRosterRepository(Protocol):
# Persists selectable entries used by HITL node configuration.
def get_entry(self, roster_id: HumanRosterId, roster_entry_id: RosterEntryId) -> RosterEntry | None: ...
# Helps keep one roster row per contact identity within a roster.
def find_by_contact_identity(self, roster_id: HumanRosterId, contact_identity_id: ContactIdentityId) -> RosterEntry | None: ...
# Returns only entries that can be selected by new HITL nodes.
def list_selectable_entries(self, roster_id: HumanRosterId, query: RosterQuery) -> Sequence[RosterEntry]: ...
def save_entry(self, entry: RosterEntry) -> None: ...
class AccountDirectoryPort(Protocol):
# Read-only adapter over Dify Account and TenantAccountJoin.
def get_account(self, account_id: AccountId) -> AccountProfile | None: ...
# Used by contact creation, IM sync, import, and recipient canonicalization.
def find_account_by_email(self, email: NormalizedEmail) -> AccountProfile | None: ...
# Uses the domain policy to choose the underlying TenantAccountJoin query.
def list_roster_source_members(self, domain: ApprovalDomainContext) -> Sequence[WorkspaceMemberProfile]: ...
# Used at submission time to reject approvers who no longer satisfy domain membership rules.
def has_active_membership(self, domain: ApprovalDomainContext, account_id: AccountId) -> bool: ...
class IMCredentialResolver(Protocol):
# Resolves the effective IM installation without exposing where credentials came from.
def resolve_installation(self, domain: ApprovalDomainContext, provider: IMProvider) -> IMInstallation | None: ...
# Returns provider, credential handle, callback metadata, and capability context for sending.
def resolve_sender(self, domain: ApprovalDomainContext, provider: IMProvider) -> IMSenderContext | None: ...
class IMBindingRepository(Protocol):
# Resolves the effective binding according to the domain's override policy.
def get_effective_binding(self, domain: ApprovalDomainContext, contact_identity_id: ContactIdentityId, provider: IMProvider) -> IMIdentityBinding | None: ...
# Used by IM card callbacks to map platform user identity back to a Dify contact.
def find_by_provider_user(self, domain: ApprovalDomainContext, provider: IMProvider, provider_user_id: str) -> IMIdentityBinding | None: ...
def save(self, binding: IMIdentityBinding) -> None: ...
class IMDirectorySyncGateway(Protocol):
# Provider-specific gateway. It returns directory data but does not create contacts.
def list_members(self, installation: IMInstallation) -> Sequence[IMDirectoryMember]: ...
class RecipientResolver(Protocol):
# Converts node selectors into canonical runtime recipients and resolution records.
def resolve(self, domain: ApprovalDomainContext, request: RecipientResolutionRequest) -> RecipientResolutionResult: ...
class NotificationPlanner(Protocol):
# Chooses IM card, IM fallback URL, Email, or skipped delivery per recipient.
def plan(self, domain: ApprovalDomainContext, task: HITLTask, recipients: Sequence[ResolvedRecipient]) -> NotificationPlan: ...
class NotificationDispatcher(Protocol):
# Performs side effects and returns durable delivery attempts for audit and Last Run.
def dispatch(self, plan: NotificationPlan) -> Sequence[DeliveryAttempt]: ...
class RecipientAuthenticator(Protocol):
# Identifies the actor from Dify login, IM platform identity, magic link, or OTP.
def authenticate(self, domain: ApprovalDomainContext, request: ApprovalAccessRequest) -> SubmissionIdentity: ...
# Checks the authenticated actor against allowed approvers and current safety state.
def authorize_submission(self, domain: ApprovalDomainContext, task: HITLTask, identity: SubmissionIdentity) -> ApprovalDecision: ...
class HITLTaskRepository(Protocol):
# Persists immutable recipient snapshots when the task is created.
def create(self, task: HITLTask) -> HITLTaskId: ...
def get(self, task_id: HITLTaskId) -> HITLTask | None: ...
# Must be atomic so only one allowed approver can submit a pending task.
def mark_submitted(self, task_id: HITLTaskId, submission: SubmissionRecord) -> None: ...
class AuditLogPort(Protocol):
# Stores domain audit events without leaking secrets or raw sensitive content.
def record(self, event: AuditEvent) -> None: ...
class SecretStore(Protocol):
# Stores provider credentials and returns opaque handles for domain records.
def put(self, owner: CredentialOwnerRef, secret: SecretPayload) -> IMCredentialHandle: ...
# Secret access should be limited to infrastructure code that actually sends requests.
def get(self, handle: IMCredentialHandle) -> SecretPayload: ...
```
## 应用服务
| 服务 | 说明 |
| --- | --- |
| `ApprovalDomainResolver` | 从 workspace locator 或 task 解析 `ApprovalDomainContext`,隔离 CE / EE / SaaS 拓扑差异。 |
| `RosterBootstrapService` | 根据 `ApprovalDomainContext.roster_policy` 初始化和同步 Human Roster。SaaS / CE 通常同步 workspace memberEE 可使用不同 policy,不默认添加企业全量联系人。 |
| `ContactDirectoryService` | 创建 account-backed contact、拒绝错误 external contact、管理企业级 IM binding。 |
| `HumanRosterService` | 添加 Dify member、添加 external contact、移除或转换 roster entry、计算 selectable 状态。 |
| `IMIntegrationService` | 保存 IM app installation、测试连接、解析 callback URL、触发通讯录同步。 |
| `IMDirectorySyncService` | 从 provider 通讯录同步成员,优先匹配 IM user id,再按 email 匹配 `Account`,未匹配进入 unmatched list。 |
| `RecipientResolutionService` | 将节点配置中的 `RecipientSelector` 解析为 `ResolvedRecipient`,执行 canonicalization 与去重。 |
| `HITLTaskApplicationService` | 创建 `HITLTask`、冻结 snapshot、调用 notification planner 和 dispatcher。 |
| `SubmissionApplicationService` | 处理 IM card、fallback URL、Email OTP、Dify login 等提交入口,执行 authenticate 和 authorize。 |
| `HITLMigrationService` | 迁移旧 email recipient;能匹配 workspace member 则转 contact selector,否则保留 one-time email。 |
## 主要流程
### Roster 初始化
1. 系统边界拿到 `workspace_id` 后调用 `ApprovalDomainResolver.resolve_for_workspace`
2. `RosterBootstrapService` 读取 `domain.roster_policy``domain.human_roster_id`
3. SaaS / CEadapter 基于现有 `TenantAccountJoin` 查询 roster source members,为成员创建或更新 `ContactIdentity``RosterEntry`
4. EE:不导入企业全量联系人;workspace admin 通过 `HumanRosterService.add_dify_member` 添加。
5. 成员离开时不物理删除 `RosterEntry`,改为 `left_workspace``pending_review`
### 创建 HITL task
1. workflow 运行上下文用 `workspace_id` 解析 `ApprovalDomainContext`,后续服务不再直接传 `workspace_id`
2.`HumanInputNodeData.notification_policy` 读取 `RecipientSelector`
3. `RecipientResolutionService.resolve(domain, request)` 解析 roster entry、one-time email、dynamic email、current initiator。
4. 对解析结果做 canonicalization,合并同一 approver 的多渠道 endpoint。
5. `IMCredentialResolver``IMBindingRepository` 基于 `domain.im_integration_policy` 为每个 contact 解析 IM endpoint。
6. 创建 `HITLTask` 和 immutable `TaskRecipientSnapshot`
7. `NotificationPlanner` 按能力矩阵选择 IM card、IM fallback URL 和 Email。
8. `NotificationDispatcher` 发送并写入 `DeliveryAttempt`
9. 解析、跳过和失败原因进入 `RecipientResolutionRecord` 与 audit log。
### 提交 HITL task
1. 打开 URL 或 IM card callback 时先定位 `HITLTask`,但 URL 本身不代表审批权限。
2. `ApprovalDomainResolver.resolve_for_task` 恢复当前审批域上下文。
3. `RecipientAuthenticator.authenticate(domain, request)` 基于 Dify login、IM platform identity、Magic link 或 Email OTP 识别提交人。
4. `authorize_submission(domain, task, identity)``AllowedApprover` 和当前安全状态重新校验。
5. 已提交、超时、取消、账号禁用、成员离开、external contact 删除、IM binding 删除、IM identity mismatch 均拒绝提交。
6. 成功提交后只允许一次状态转移到 `submitted`
## 与现有模型映射
| 现有对象 | 建议领域语义 |
| --- | --- |
| `Account` | account-backed `ContactIdentity` 的身份来源。 |
| `Tenant` | 系统入口 locator 和现有存储 owner;应用服务入口用它解析 `ApprovalDomainContext`。 |
| `TenantAccountJoin` | membership 数据源,用于 resolver / adapter 推导 roster source members 和提交时成员状态。 |
| `HumanInputNodeData` | 继续承载表单 schema;新增 `NotificationPolicy`,不要把 Contact 逻辑塞进旧 `EmailRecipients`。 |
| `HumanInputForm` | `HITLTask` 的持久化实现;应能恢复或重建 task 所属 `ApprovalDomainContext`。 |
| `HumanInputDelivery` | delivery method / delivery batch。后续需要补足 per recipient per channel record。 |
| `HumanInputFormRecipient` | `TaskRecipientSnapshot` 和 access token holder。它不是 Contact。 |
## 分阶段落地建议
1. 先实现 `ApprovalDomainResolver``ApprovalDomainContext`,把 `workspace_id` 限制在系统入口和 adapter 内。
2. 增加 `ContactDirectory``ContactIdentity``HumanRoster``RosterEntry`,让节点配置保存 roster-scoped contact reference。
3. 扩展 HITL node config,引入 `NotificationPolicy``RecipientSelector`,兼容旧 `EmailRecipients`
4. 实现 `RecipientResolutionService.resolve(domain, request)`,把旧 email member / external email 迁移到新 recipient model。
5.`HumanInputFormRecipient` payload 中增加 snapshot 结构,保留旧 payload 兼容读取。
6. 引入 `IMIntegrationService``IMCredentialResolver``IMBindingRepository` 和 directory sync / search,所有解析均基于 `ApprovalDomainContext`
7. 补齐 per recipient per channel delivery record、resolution record 和 audit event。
## 已确认决策与剩余风险
以下产品规则已经确认,不影响核心抽象,只影响 policy、权限矩阵或交互实现:
1. Workflow editor 禁止创建 `ExternalContact`,仅允许输入 one-time email。
2. Pending task 在 external contact 删除或 IM binding 删除后拒绝提交。
3. IM identity 一期通过 directory sync / search 选择 provider user。
4. SaaS dynamic email、Magic link、OTP、单 task recipient 数量和每日发送量属于 guardrail 配置,不影响 Contact / Roster / Recipient 抽象。
剩余会影响抽象演进的问题只有一个:SaaS 未来是否会出现“一个企业多个 workspace”。当前没有这个计划,所以 `ApprovalDomain` 暂不需要独立持久化;但领域层仍通过 `ApprovalDomainContext` 隔离拓扑细节,避免未来从 `tenant_id` 迁移到独立企业 ID 时扩散到 Contact、Roster、IM 和 HITL Runtime。