Skip to content

Call State and Recording

This content is not available in your language yet.

0. 规范语言

本文中的规范关键字(MUST / SHOULD / MAY 等)按 conformance/normative-language.md 解释;仅大写形式具规范约束力。

1. 目标与范围

本文件定义通话 / 会议的 durable 真源:ak.call.create 创世、通话模型与状态机、ak.call.state payload 字段语义与 reducer 校验规则、participant 绑定的落地校验,以及录制 / 转写的生命周期。实时媒体本身不进入 Realm Event history;高频信令走 ephemeral 通道,但它们只能引用已 accepted 的 Call。

边界:

  • ephemeral 信令(offer/answer/candidate、ICE/TURN、一对一通话、推送、ak.call.* 权限模型)见 webrtc-signaling.md。
  • 媒体服务发现、token / participant binding 兑换、focus 选举、媒体 E2EE 帧密钥注入见 media-service-binding.md。

2. 通话模型

模式适用说明
p2p1 对 1 或极小规模双方直接 WebRTC 连接,必要时经 TURN server。
mesh3-4 人小会每个客户端与其他客户端建连接,复杂度高,不建议默认。
sfu多方会议默认Selective Forwarding Unit 转发 RTP,不解密 E2EE 内容。
mcuPSTN / 录制 / 低端设备Multipoint Control Unit 混流,通常会接触明文或解密后媒体,MUST 强提示和审计。

默认多人会议 SHOULD 使用 SFU。

3. Call Morph

会议或通话 SHOULD 用标准 Morph 表示:

{
"morph_kind": "call",
"realm_id": "ak:realm:...",
"title": "Design review",
"fields": {
"call_id": "ak:call:ARzVic5s2NUShp82C8GPo-shbkm7isUWyvILLThc3aNL",
"mode": "sfu",
"state": "ringing",
"started_at": null,
"ended_at": null
}
}

Morph 中的 call_id 只是对已创建 Call 的引用;Morph 不创建 Call,也不得在 ak.call.create accepted 前先行发布。

state(通话生命周期,完整枚举、合法转换与终态见 §4.2):

  • scheduled
  • ringing
  • connecting
  • active
  • ended
  • missed
  • failed
  • cancelled

录制不是通话生命周期的一部分,走独立 recording_transition delta,与 state_transition 正交,见 §4.2 / §5。

4. 会议状态事件

每个 Call 首先由一条 accepted ak.call.create 创建:

{
"kind": "ak.call.create",
"realm_id": "ak:realm:...",
"payload": {
"initial_state": "ringing"
}
}

create payload MUST 省略 call_id;call_id = retype(event_id, "call"),因而使用与 Event 相同的 33-octet / 44-character content-bound token。只有在这条 Event accepted 后,才能签发媒体 token、发送 offer/answer/candidate/focus_join 信令、创建引用该 Call 的 Morph,或提交 ak.call.state。接收方对未解析到 accepted create 的任何引用 MUST fail closed,不得为裸 call_id 自动建立占位 Call。

后续状态以 durable event 记录:

{
"kind": "ak.call.state",
"realm_id": "ak:realm:...",
"payload": {
"call_id": "ak:call:ARzVic5s2NUShp82C8GPo-shbkm7isUWyvILLThc3aNL",
"state_transition": {
"from": "connecting",
"to": "active"
},
"focus": {
"mode": "sfu",
"session_focus": "fra-1"
},
"roster_delta": {
"op": "join",
"participant": {
"actor_id": {
"kind": "account",
"account_id": {
"principal_id": "ak:did_core:webvh:zBfFLx7gUhQB7dPEQCj3qeHZR",
"station_id": "ak:did_core:webvh:z6mkfixturestationexample"
}
},
"device_id": "ak:device:01964137-0000-7000-8000-000000000000",
"joined_at": "2026-04-26T00:00:00Z",
"foci_preferred": [
"fra-1",
"us-east-1"
],
"participant_id": "ak:rtc_participant:0198c2f4-0000-7000-8000-000000000000",
"participant_binding": {
"expires_at": "2026-04-26T00:05:00Z",
"issuer_kid": "did:webvh:zCxjAemtszNh7bTFGWFS4m8gv:media.example#key-1",
"sig": "base64url..."
},
"media": {
"audio": true,
"video": true,
"screen": false
},
"realm_id": "ak:realm:...",
"call_id": "ak:call:ARzVic5s2NUShp82C8GPo-shbkm7isUWyvILLThc3aNL",
"focus_id": "fra-1"
}
}
}
}

4.1 字段语义(normative)

typed current result 归属(normative):每项 write 先按 canonical registry 的 condition 求值。只含 ready/failed 产物更新的 Event 为普通数据;只要含安全 write,整个 Event 原子等待唯一安全确认。

承载字段typed current result familydomain reducer值形状
state_transitioncall_statecommit-ordered projection领域状态
focuscall_focuscommit-ordered projectionregister
recording_transition.to=stoppedcall_recording_statecommit-ordered projection捕获许可状态
recording_transition.resultcall_recording_artifactstream-ordered reducer{status,details,expected_revision}
transcript_transition.to=stoppedcall_transcript_statecommit-ordered projection捕获许可状态
transcript_transition.resultcall_transcript_artifactstream-ordered reducer{status,details,expected_revision}
moderation_deltacall_moderationcommit-ordered projectionset
roster_deltacall_rostercommit-ordered projectionset
mute_override(v1 禁用)call_mute_override(保留形状,无可接纳写入)不执行单 leg register,不可当多 leg 集合

所有状态与结果按所属 stream 的 RealmCommit 顺序确认。会覆盖现有结果的 Event 必须携带该领域 current result 的 expected_revision;竞争写至多一笔成功。result 不授予采集、密钥或读取权限。

未变更的轴 MUST NOT 产生 projected write(normative):payload.call_id 之外的每个准入 delta 字段都是可选的,单条 ak.call.state MUST 只携带本次实际变更的轴,并且至少携带一个准入 delta(schema anyOf)。上表准入轴的每条 result_writes[] 都是条件性目标;字段存在则对应 write 必需,字段缺席则对应 write MUST NOT 产生。mute_override 在 schema 阶段拒绝,不能触发保留的 result-write 行。recording_transition.result / transcript_transition.result 各自额外产生 result typed current result write。完整 op 由 registry result_projection 派生,producer 不得自选 from / to / tag / value。

捕获态按段切分:录制 / 转写 typed current result 的 subject 是 (call_id, recording_id) composite,因此同一通话的多段捕获天然落在不同 typed current result,互不冲突。段键选用 payload.recording_id 而不是 start Event 的 event_id:envelope.event_id 只在 create-once 对象的 id:<种类> 派生形态下可用(../conformance/encoding.md §9.5.1),而录制段不是这样的对象——一次通话里有多段捕获,start Event 不是任何一段的身份来源;而 recording_id 已经是 ak.call.recording.start 的 required 字段、已按 §5 要求在同一通话内逐段唯一、且已是录制 / 转写 key exporter Context 的 member,因此是两侧都能派生的同一个键。ak.call.recording.start 按 payload.capture_kind 写入 call_recording_state 或 call_transcript_state,subject 为 [payload.call_id, payload.recording_id];后续 ak.call.state 用 recording_transition.recording_id / transcript_transition.recording_id 指向同一段,其值 MUST 与该段 start event 的 recording_id 逐字节相同。capture_kind 是 required 字段,MUST NOT 由 missing-field default 推断——它决定目标 typed current result family。

捕获 transition 与 result 分离(normative):开始后安全状态为 capturing;停止命令只把安全状态推进到 stopped。ready/failed 结果必须从 stopped 发布并引用精确 capture_stop_event_id,验证 stop 所属 call、段、capture kind 和唯一确认结果。result 保存 {status,details,expected_revision};治理 Station按 stream 顺序执行并拒绝 stale revision,且不能重开捕获。start 的 pending result 与许可创建在同一原子提交内生效。

  • focus:是 call_focus 的完整目标值,mode required、session_focus optional。首个 committed session_focus 后,任何后继 focus 写入都 MUST 原样携带它;省略或改写均以 session_focus_already_committed 拒绝。mode 只允许按 §6 单向升级。
  • roster_delta:participant 的稳定领域身份是 (call_id, actor_id, device_id)。op=join 创建或更新该 participant;op=leave 携带同一 actor_id/device_id 与 roster expected_revision。未知 participant、跨 call、身份不匹配或 stale revision MUST 拒绝。每 Event 只允许一个 roster delta;effective roster 上限为 1,000。
  • roster_delta.participant 的 durable 身份最小化:除 Realm policy 明确要求实名审计且已披露外,actor_id MUST 使用 call-scoped pairwise DID,device_id MUST 使用仅在该 call 内稳定的 typed device alias。joined_at 若写入 durable event MUST 向下取整到 5 分钟 bucket。
  • roster_delta.participant.foci_preferred:客户端本地 focus 偏好列表;后加入者不得改变已 committed session_focus。
  • roster_delta.participant.participant_id:来自 token exchange 响应的 SFU-local handle,scope 限 (call_id, focus_id, sfu_did)。
  • roster_delta.participant.participant_binding:token issuer 对 (realm_id, call_id, focus_id, actor_id, device_id, participant_id, expires_at) 的签名承诺。reducer MUST 验证:
    1. issuer_kid 解析到的 service DID 出现在当前 epoch ak.realm.media_service.service_id;
    2. 从 enclosing Event/Call 取得 realm_id / call_id,从该 participant entry 取得 focus_id / 完整 actor_id / device_id / participant_id,并与 binding 的 expires_at 重建唯一签名七元组;写入时 entry 的 focus_id 必须等于已接受的 selected focus,后续 focus 变化不得重解释旧 entry;
    3. expires_at > event created_at(不接受已过期 binding);
    4. sig 通过签名验证。 任一失败 → failed_precondition reason="participant_binding_invalid"。
  • P2P / mesh roster 分支:当当前 focus.mode ∈ {p2p, mesh} 且尚无 session_focus 时,roster join MUST 省略 participant_id 与 participant_binding;participant identity 由 signed answer proof 中的 (call_id, actor_id, device_id) 组成,admission service 验证 answer 后接受 durable join。 当 focus.mode ∈ {sfu, mcu} 或已 committed session_focus 时,上述两个字段反而都 MUST 出现并按本节四项校验。模式分支由包含 roster delta 的同一 accepted call-state basis 决定,producer 不得自行声明第三个判据。
  • mute_override:v1 暂不开放主持人强制静音。ak.call.state 只要携带此字段(即使同时携带其它合法 delta)就 MUST 以 schema_violation 拒绝,零 Event/Commit/typed current result 写入;旧 call-only call_mute_override selector 与单 leg value 不得被解释为多 leg 集合。该保留形状不授权生产端写入。普通通话、自主 mute_state{by=self}、kick/ban/end-for-all 仍按各自合同接纳。
  • moderation_delta.op=remove_participant:以 (call_id, actor_id, device_id?) 作为稳定领域目标;kick MUST 含 device_id,ban MUST 省略它。moderation_delta.op=restore_participant 必须携带 moderation expected_revision,且只能恢复 actor 一致的 active ban,不能恢复 kick。

高频 speaking、自主 mute/video 状态 SHOULD 走 encrypted Signal Extension;主持人强制静音在 v1 失败关闭,不能用仅作用于客户端的信令冒充服务端媒体权限。

4.2 state 状态机(normative)

call_state_payload.state_transition 是通话生命周期 delta,显式携带 from / to。它写入 call_state,且只承载这一条轴。合法转换、终态与并发语义如下:

state语义合法后继终态?
scheduled已排期,未开始ringing、connecting、cancelled、missed、failed(排期通话自动启动失败)否
ringing呼叫已发起,待应答connecting、active、missed、cancelled、failed否
connecting应答后媒体协商中active、failed、ended否
active通话进行中ended、failed否
ended正常结束—(终态)是
missed未应答—(终态)是
failed出错失败—(终态)是
cancelled连接前取消—(终态)是
  • 初始 state 集合:ak.call.create.payload.initial_state MUST ∈ { scheduled, ringing, connecting },并以 from=null 初始化 call_state。ak.call.state.state_transition.from 不再允许 null;它必须匹配已 accepted head。创世直接进入 active 或任一终态 MUST failed_precondition,reason_code="call_state_transition_invalid"。
  • 终态集合:{ ended, missed, failed, cancelled }。reducer MUST 拒绝从任一终态转出(单调推进),违反用 failed_precondition reason="call_state_terminal"。
  • 非法转换通用规则:源 state 为非终态时,任何不在上表”合法后继”列内的 state 转换 MUST failed_precondition,reason_code="call_state_transition_invalid"(见 artifacts/registry/error-code-registry.json);源为终态时用 call_state_terminal,二者不混用。
  • 同状态重放:同一 basis 上重复提交相同 from -> to 转换是幂等 no-op;reducer MUST 不产生新的分叉 head,也不得把同值重放当成非法转换。
  • 并发命令:相同安全前态的竞争命令按唯一确认顺序至多一个成功。其余返回持久前置失败,不形成权限多头。
  • 终态吸收:一旦某 accepted head 进入终态,任何后续转出都按 call_state_terminal 拒绝;终态不能被并发 winner 规则覆盖,因为本状态机没有 winner 规则。

超时推进(normative):ring_timeout_ms 默认且最大为 60,000;scheduled_start_grace_ms 默认且最大为 300,000;connecting_timeout_ms 默认且最大为 120,000。计时锚分别为进入 ringing 的 accepted event created_at、排期开始时间、进入 connecting 的 accepted event created_at。focus / token issuer(无 focus 时为 Station)MUST 在窗口到达后以当前 accepted head 为 CAS basis 提交显式状态事件:ringing → missed、未在 grace 内开始的 scheduled → missed、connecting → failed(state_transition.failure_reason_code="media_negotiation_timeout";该成员在 to="failed" 时必填、其余转换禁止携带)。计时器本身不得直接改写 reducer state。若迟到的 active 与超时终态竞争,按唯一安全确认顺序及精确 revision 处理;提交方 MUST NOT 用本地到达顺序选择 winner。

  • 空 roster 终态推进:当 focus / token issuer 观察到 active media roster 为空时,最后离开的、仍持有 ak.call.join 的成员 SHOULD 立即提交 state_transition={from:"active",to:"ended"};focus / token issuer MUST 启动 call_empty_timeout_ms(默认且最大 120,000 ms),超时前 roster 仍为空时 MUST 代表该 call 提交同一显式 transition。若没有 focus(纯 P2P),承载该 Realm 的 Station MUST 以相同窗口根据 authenticated ephemeral leave / leg expiry 证据推进终态。任何新 leg 在该窗口内重新加入会取消计时;终态 accepted 后不得复活,重新加入必须创建新 call_id。

录制维度(与通话 state 正交,normative):ak.call.recording.start 创建一段 capturing 安全状态;唯一后继是 stopped,stopped 不可转回 capturing。新段使用新的 recording_id 并重新经过许可检查。recording_transition / transcript_transition 的 wire from/to 按 schema 的精确分支解释,ready/failed 的 from=stopped 是产物 provenance 前置,不是写安全状态。

  • 停止必须引用确切当前安全 revision;相同前态竞争至多一个成功,失败不改变许可。
  • ready/failed 必须引用已确认 stop,先停止捕获才发布产物。缺 stop 或属于其它段的 stop 均不生效。
  • 结果独立按所属 stream 的 Commit 顺序与 expected_revision 更新;异值竞争不冻结其它段或主通话状态。
  • roster/moderation 更新在同一 authority commit 事务中原子执行;不能脱离该序列释放 token 或移除访问屏障。

5. 录制与转写

录制和转写默认关闭,必须由 Realm policy 和 call capability 显式允许。

启动录制:

{
"kind": "ak.call.recording.start",
"realm_id": "ak:realm:...",
"payload": {
"call_id": "ak:call:ARzVic5s2NUShp82C8GPo-shbkm7isUWyvILLThc3aNL",
"recording_id": "rtc-recording-0196441d-0000-7000-8000-000000000000",
"recording_agent": "did:webvh:zCYG7PrN3Yt1TdX4X8gfYFA4R:recorder.example",
"capture_kind": "recording",
"mode": "audio_video",
"visible_notice": true
}
}

要求:

  • 需要 ak.call.record capability。ak.call.recording.start 通过 required capture_kind(recording / transcript)区分录制与转写两条平行生命周期(转写见 §5.1);它选择目标 typed current result family(call_recording_state / call_transcript_state),因此 MUST 显式携带,缺失 MUST schema_violation,MUST NOT 由 missing-field default 推断。

  • payload.mode MUST 显式携带,封闭为 audio / audio_video;无缺省值,缺失 MUST schema_violation。

  • payload.visible_notice MUST 显式为 true;客户端 MUST 对所有参会者显示录制中。

  • payload.result MUST NOT 携带 recording_start_event_id 或 transcript_start_event_id:本 Event 的 ID 依赖 payload digest,写入自身 ID 会形成无解自引用。result.retention.consent_confirmed MUST 为 true,否则 reducer 在创建 capture 领域状态 typed current result 前拒绝 recording_consent_required。Event 完成 digest / ID 校验后,reducer 按 capture_kind 把完整 accepted Event identity 写入对应 projected result typed current result;该 receiver-derived 字段不属于原 Event preimage。start event 原子写入 capture FSM 与独立 result typed current result,不能先进入捕获态再补交同意事实。

  • payload.recording_id MUST 是该录制 artifact lifecycle 的稳定 opaque string,并进入 recording key exporter Context;缺失时 recording start event MUST schema_violation reject。它不是 ak:* typed ID;最终持久化产物仍通过 Arkret blob / Morph / artifact 引用暴露。由于 recording_id 是跨实现密钥派生输入(进入 §5 第 3 步的 Context),其 canonical 形态 MUST 由 ak.call.state recording start event 一次性固定并逐字节保留:取值 MUST 为 ASCII 子集 [A-Za-z0-9._-]、长度 1–128 字节;发送方写入后该字符串即为 canonical,接收方 MUST NOT 做任何 normalize(大小写折叠、Unicode NFC/NFKC、trim、re-encode 等),并 MUST 在所有引用该录制的 event / key 派生中逐字节复用 start event 的原值。任何对 recording_id 的本地规范化都会令派生出的 recording key 与发送方分裂、导致解密失败。

  • 手动停止录制不注册独立 ak.call.recording.stop event;holder of ak.call.record 通过 ak.call.state 写 recording_transition={recording_id,from:"recording",to:"stopped"}。若同时携带 recording_transition.result,其中的 recording_start_event_id 继续指向该段的 ak.call.recording.start 作为 provenance;该 result 写入独立 result typed current result,不进入 FSM transition op。stopped 是该录制段的终态,不要求产生 artifact;若 backend 已经产出可用 artifact,后续 MAY 以同一 recording_start_event_id 写 to="ready",否则保持 stopped。

  • 同一通话允许多段录制。ready / failed / stopped 之后再次进入 recording 时,MUST 先接受新的 ak.call.recording.start,且新的 recording_id MUST 不同于该 call 任何既有 recording start 的 recording_id。物化投影 MAY 只展示最新捕获态,但历史段以各自 ak.call.recording.start 与后续 ak.call.state event 保持可审计。

  • 录制 artifact MUST 作为 encrypted Blob 或受控 media object 存储。

  • Backend-generated recording 必经 Arkret blob pipeline(参见 media-service-binding.md §8.1):backend 可能自带录制能力(LiveKit Egress、Janus recording plugin 等),但生成的 artifact MUST:

    1. 作为加密 blob 上传到 Arkret media service(通过 media-and-blob.md 的 authenticated upload 端点),不得 backend 自行托管。
    2. 上传请求携带 recording_initiator_capability_ref,证明该 recording 由具备 ak.call.record 的 actor 发起。
    3. 加密 key MUST 由 Arkret 协议层提供(与 media-service-binding.md §8.1 同源,从 MLS exporter 派生),backend 不持久化明文。Recording artifact key label 固定为 "ak.rtc-recording-key/v1",Context=canonical_json({realm_id, call_id, focus_id, recording_id, media_service_id, recording_start_event_id}),输出 32 bytes;不得复用 SFrame label "ak.rtc-frame-key/v1" 或空 Context。
    4. 入库后通过 ak.call.state 的 recording_transition 发布 lifecycle state,并在 recording_transition.result 引用 content digest、duration、media type、retention policy、recording_start_event_id 与 artifact。 绕过该 pipeline(如 backend 直接对外暴露 recording URL)MUST 被客户端拒绝并报 recording_artifact_pipeline_bypassed。这保证 backend 是 “录制执行单元” 而非 “录制档案库”。
  • 录制结果 MUST 通过已注册的 ak.call.state 写入 recording_transition:to 为 ready / failed / stopped,与通话 state_transition 正交(见 §4.2);需要结果事实时在 recording_transition.result 中引用 recording_start_event_id。to="ready" 时,recording_transition.result.artifact MUST 符合 call-recording-artifact.schema.json,其 schema MUST 为 ak.schema.call_recording_artifact.v1;该 schema identity 已固定 recording artifact family,artifact 不携 artifact_kind。artifact 还 MUST 绑定同一 realm_id / call_id / recording_id / recording_start_event_id、blob_ref、content_digest、ciphertext_digest、duration_ms、media_type、encryption.exporter_label="ak.rtc-recording-key/v1"、encryption.context、retention、produced_by 与 recording_initiator_capability_ref。result 中的 content_digest / duration_ms / media_type / retention_policy_id / retention 是便于投影和查询的镜像字段;若与 artifact 同名事实不一致,reducer / consumer MUST fail closed schema_violation。to="failed" 时 SHOULD 携带 failure_reason_code,MUST NOT 携带 backend 直出 URL、明文路径或明文片段。v1 不注册独立的 ak.call.recording.result 或 ak.call.recording.stop event kind;实现不得把这些裸名写入 Event Envelope。 录制与转写的 failure_reason_code 共用封闭 core 集:media_negotiation_timeout / permission_denied / backend_unavailable / media_source_unavailable / storage_failed / policy_revoked / consent_withdrawn / integrity_failed。扩展值 MUST 使用 x_ 前缀并匹配 ^x_[a-z0-9_]{1,62}$;其它值 MUST schema_violation。

  • 转写需要 ak.call.transcribe,转写文本应作为 Morph 或 Artifact,并遵守同一 Realm policy。

5.1 转写生命周期(normative)

转写与录制平行:默认关闭,MUST 由 Realm policy 与 ak.call.transcribe capability 显式允许。转写态走 ak.call.state 的独立 transcript_transition(to ∈ { stopped, ready, failed },缺省=未转写;进入 transcribing 只能由下述 ak.call.recording.start 派生),与 state_transition 及 recording_transition 三者正交。

  • 启动转写复用 ak.call.recording.start event kind,但 capture_kind="transcript"(该字段 required,无 missing-field default);其 recording_id 同样是稳定 opaque 句柄,约束与 §5 录制 recording_id 完全一致(ASCII 子集 [A-Za-z0-9._-]、1–128 字节、逐字节 canonical、接收方 MUST NOT normalize),并进入 transcript key exporter Context。缺少 ak.call.transcribe 时 MUST 拒绝,reason_code="transcription_denied"。
  • 客户端 MUST 对所有参会者显示转写进行中提示(与录制提示同等级别)。
  • 转写文本 MUST 作为 encrypted Blob / 受控 media object 存储,绝不明文落 backend。转写 artifact 的加密 key MUST 由 Arkret MLS exporter 派生,label 固定为 ASCII 字符串 "ak.rtc-transcript-key/v1"(与 SFrame "ak.rtc-frame-key/v1"、录制 "ak.rtc-recording-key/v1" 区分),Context=canonical_json({realm_id, call_id, focus_id, recording_id, media_service_id, transcript_start_event_id}),输出 32 bytes;canonical 登记见 ../../artifacts/registry/exporter-label-registry.json。复用其它 label、空 Context,或接受 backend / KMS 自生成的 transcript key MUST fail closed transcription_artifact_pipeline_bypassed。
  • 转写结果通过 ak.call.state 写入 transcript_transition,并在 transcript_transition.result 中引用 transcript_start_event_id;to="ready" / to="failed" 时还 SHOULD 携带 content digest、media type、language 与 retention policy。手动停止时写 to="stopped",不要求产生 artifact。
  • v1 不为转写注册独立的 result / stop event kind;转写态变化一律通过 ak.call.state 写入。

5.2 录制 / 转写 retention policy(normative)

录制与转写 artifact 的保留期、删除触发、审计锁定与二次确认走对应 capture transition 的 result.retention(schema 见 call_recording_retention)。规则:

  • 保留期:retention.retention_expires_at 是协议固定的最早可删除时间;缺省时由 retention_policy_id 指向的 Realm retention policy 决定。写入时 MUST NOT 为过去时刻。
  • 删除触发:retention.deletion_trigger ∈ { retention_expiry, manual, realm_policy, participant_erasure }。participant_erasure 对应某参与者发起 erasure 时对其媒体片段的级联删除(见 account erasure 流程)。实际删除尝试的触发事实写入 result artifact 的 deletion_audit.trigger(或转写后续定义的等价 artifact 字段);该值 MUST 与 retention 的 deletion_trigger 一致,不得由 Blob 服务或媒体 backend 自行改写。
  • 审计锁定:retention.audit_lock=true 时该 artifact 处于 legal / audit hold,任何删除(含 retention 到期、manual)MUST 被拒绝 legal_hold_active,直到 audit 级 action 解除锁定;audit_lock 优先于 retention_expires_at 与 capability。解除后删除(normative):audit_lock 被 audit 级 action 解除后,删除按原本适用的 deletion_trigger 继续(retention_expiry 若已到期、否则按触发来源取 manual / realm_policy / participant_erasure),不新增独立 trigger 枚举值;该次删除的 deletion audit MUST 记录解锁来源(legal_hold_ref + 对应 trigger_event_id),使”曾被 legal hold 阻塞、解锁后按原 trigger 删除”在审计上可追溯。
  • 删除审计:每次删除尝试 MUST 至少在服务审计日志中记录 trigger、outcome、requested_by?、trigger_event_id?、requested_at、completed_at?、erasure_receipt_ref?、legal_hold_ref? 与 failure_reason_code?,其 wire 形态见 call-recording-artifact.schema.json#/$defs/call_recording_deletion_audit。删除完成时 MUST 产出或引用 ak.schema.erasure_receipt.v1(可作为 ak.audit.erasure_receipt durable event),并把 erasure_receipt_ref 绑定到 deletion audit;因 legal hold 阻塞时 MUST 记录 outcome="blocked_by_legal_hold" 与 legal_hold_ref,不得伪造成已删除。
  • 客户端二次确认:录制 / 转写进入捕获态只能由 ak.call.recording.start 完成;接受该 Event 前 MUST 取得用户的第二次显式同意,并将事实记入 payload.result.retention.consent_confirmed=true。reducer MUST 原子验证该事实并写入独立 result typed current result,缺失或不为 true 时 MUST failed_precondition reason_code="recording_consent_required";ak.call.state 不得提交 to="recording" / to="transcribing" 绕过 start gate。
  • consent_confirmed 的保证类别(normative,诚实标注):consent_confirmed=true 是流程性约束,不是密码学同意证明——它由发起方客户端单方面置真,协议层无法强制其真实性。实现、UI、采购或合规文案 MUST NOT 把 consent_confirmed=true 表述为”已获得(被录制方的)密码学同意”或等价措辞,避免 false-positive 合规声明;该字段只表示”发起方声明已在本端取得用户二次确认”。
  • per-participant consent acknowledgment(更强合规 profile,normative):每个被录制方设备签名的 consent acknowledgment,其 signing input MUST 覆盖 (call_id, recording_artifact_ref 或 capture epoch, consenting_actor_id, consenting_device_id, consented_at),签名身份按 device-lifecycle.md §8.2/§8.3 的设备验签公钥目录解析并 fail-closed 验证。security_class=high-confidentiality 或启用 attested / disclosed audit profile 的 Realm MUST 启用该门禁;其它 Realm MAY 启用。启用时,reducer MUST 对缺少任一当前被录制方有效 acknowledgment 的捕获态 failed_precondition reason_code="recording_consent_required",且成员加入、设备切换或 capture epoch 改变后 MUST 重新取得 ack 后才能继续捕获。该签名集合是 disclosed_policy 之上的 attested-类强保证,可作为可审计同意证据。未启用该 profile 的部署仍只具备上一条的流程性保证,不得声称等价。
  • retention 字段是对应 recording_transition.result / transcript_transition.result 的子对象,与 §4.2 捕获态机正交;它只约束 artifact 生命周期,不改变通话主状态。

6. P2P→SFU 升级(normative)

通话可以 P2P 起步(mode="p2p"),但当并发参与者人数 > 2 时 MUST 从 P2P 收敛到 SFU。

并发升级收敛性(normative):分布式下各设备对”将达 3 人”的本地观测可能不同步,因而多台设备可能并发发起升级。这不产生 split-brain:升级 MUST 复用 media-service-binding.md §5 的 deterministic, no-vote focus 选举(由 oldest_membership 的 foci_preferred[0] 确定性选出 session_focus),且 session_focus 为 write-once(本文 §6 第 5 条 / §4.1,由 call_focus 的 commit-ordered projection CAS precondition 表达),任意子集设备并发发起的升级最终都被同一 session_focus 值吸收——首个被接受的 CAS 写入定锚,其余并发 CAS 收敛到同值或以 session_focus_already_committed 失败。因此并发升级收敛到同一 SFU focus,不依赖各设备观测同步,也不引入投票或 leader 选举。

触发与协商规则:

  1. 触发条件:任一参与设备观察到当前 active 参与者(已 accepted answer 的 leg)将达到 3 人时,MUST 发起升级,不得继续以 P2P / full-mesh 承载 3 人以上(mesh 仅 SHOULD 用于 3–4 人且不作为默认,见 §2)。
  2. focus 协商:升级 MUST 复用 media-service-binding.md §5 的 deterministic, no-vote focus 选举——由 oldest_membership 的 foci_preferred[0] 选出 session_focus 并写入首个携带 focus={mode:"sfu",session_focus} 的 ak.call.state。升级不引入新的投票或 leader 选举路径。
  3. 加入信令:各设备通过 ak.call.signal{signal_kind=focus_join}(见 webrtc-signaling.md §5)向选定 focus 迁移媒体;原 P2P leg 在所有参与者完成 focus_join 后 MUST 优雅拆除,迁移期间不得丢媒体(参照 §4.2 credential refresh 的”保留旧 allocation 直到迁移完成”原则)。
  4. mode 写入:升级落定后,下一条 ak.call.state 的 focus.mode MUST 写 sfu,且一旦 session_focus committed 即不可在本生命周期内回退到 p2p(回退 P2P 需新 call)。
  5. 单调性:session_focus 一经 committed 即 write-once——由 call_focus 的 CAS precondition 强制,改写 MUST 以 session_focus_already_committed 失败(见 §4.1);升级到 SFU 后人数回落到 2 人 MUST NOT 自动降级回 P2P。
  6. 升级失败 / 迁移中断(normative):升级编排可能在三处失败——focus 不可达(选举出的 session_focus 无法建立媒体)、某设备 focus_join 中途失败、原 P2P leg 已拆除但 SFU leg 未建成的部分迁移态。处置规则:
    • focus 不可达且无可选 focus:发起方 MUST 保留旧 P2P/mesh leg(尚未拆除时)继续承载已有媒体,并 SHOULD 在新的 accepted basis 上以下一候选 focus 重试 media-service-binding.md §5.1 的选举(基于剩余 foci_preferred);候选耗尽后,通话整体 MUST 提交 state_transition={from:"active",to:"failed"}(reason_code="call_state_transition_invalid" 不适用——这是终态推进,按 §4.2 active → failed 合法转换),不得停留在”已拆 P2P 又无 SFU”的不可解释悬挂态。
    • 单设备 focus_join 失败:不影响其它已迁移设备;该设备 SHOULD 重试 focus_join,持续失败则按本地策略以 ak.call.signal{signal_kind=leave} 退出本通话,通话 state 不因单设备迁移失败而回退。
    • 迁移期间不得丢媒体:在所有参与者完成 focus_join 之前,原 leg MUST NOT 被拆除(§6 第 3 条);若实现因故已提前拆除且 SFU 未建成,MUST 视为升级失败并按上面第一条处置(重试 focus 或转 failed),MUST NOT 静默丢弃通话状态。
    • session_focus 一旦 committed 即 write-once:升级失败重试只能在 session_focus 尚未 committed 时切换候选 focus;已 committed 后 focus 不可达只能转 failed 并由用户新建通话(§6 第 4 条回退 P2P 需新 call 同理)。

7. 通话记录(normative)

协议不定义独立的通话摘要 Event。通话到达终态后的持久记录由 call_state 终态 head(state、mode 等)与各捕获段 typed current result(call_recording_artifact / call_transcript_artifact)共同给出;客户端从这些 typed current 渲染通话记录,无需重放 ephemeral 信令。逐段捕获状态只读对应段的 capture typed current,不存在把多段折叠成单值的持久字段。