From efb2b86f5eced649b02f8e75bb95ac45877a0b7d Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 28 Sep 2026 16:06:24 +0800 Subject: [PATCH] simplify SaaS SIP configuration response --- AGENTS.md | 6 +- contracts/contracts.go | 4 +- .../local/v0.2/config-read-v0.2.schema.json | 174 ++++++++++++++++++ .../config-read-fields-v0.2-proposal.md | 100 ++++++++++ docs/contracts/config-read-v0.2.schema.json | 174 ++++++++++++++++++ ...config-read-sip-invalid-artifact-v0.2.json | 38 ++++ .../examples/config-read-sip-v0.2.json | 37 ++++ .../local-contract-manifest-v0.2.json | 2 +- .../local-contract-manifest-v0.5.json | 11 ++ docs/plan-config-read-v0.1.md | 2 +- docs/thirds/v0.2.md | 62 ++----- internal/configread/client.go | 45 +++-- internal/configread/client_test.go | 34 +++- internal/contract/contract.go | 2 +- internal/contract/schema.go | 2 + internal/dispatcher/agent.go | 22 +-- internal/dispatcher/config_read.go | 4 - internal/dispatcher/dial_policy.go | 42 +---- internal/dispatcher/dial_policy_test.go | 26 +-- .../dispatcher/local_v01_integration_test.go | 2 +- internal/dispatcher/local_v01_test.go | 16 +- .../dispatcher/sip_applied_config_test.go | 20 +- scripts/validate-local-contracts.py | 44 ++++- 23 files changed, 700 insertions(+), 169 deletions(-) create mode 100644 contracts/local/v0.2/config-read-v0.2.schema.json create mode 100644 docs/contracts/config-read-fields-v0.2-proposal.md create mode 100644 docs/contracts/config-read-v0.2.schema.json create mode 100644 docs/contracts/examples/config-read-sip-invalid-artifact-v0.2.json create mode 100644 docs/contracts/examples/config-read-sip-v0.2.json create mode 100644 docs/contracts/local-contract-manifest-v0.5.json diff --git a/AGENTS.md b/AGENTS.md index 404e444..b19b73e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,7 +165,7 @@ - 新内部消息/许可/fencing 协议需先获批;不擅自改变 SaaS 路径、字段、状态、路由或控制语义。 - **现行已发布合同(新版本生效前必须遵守)**:SaaS↔Dispatcher的全部交互唯一经RabbitMQ专用Topic订阅,双方无HTTP请求/回调/兼容通道或故障回退,包括执行、控制、查询、整体补传、AI配置/授权及recording.uploaded上传事实通知;上传不申请SaaS会话或等待verified/OSS ID回复。OSS配置/TOKEN不来自SaaS:Agent领取及显式重申请TOKEN只经D↔A Unary;本规则不禁止Agent→OSS、ARI、AI供应商HTTP(S)或gRPC的HTTP/2。 - **本轮项目内目标(尚未替换真实外部运行)**:任务(含智能体)、SIP、任务发现和按 tenant_id 的租户额度走四条只读 HTTP;呼叫、控制及其必要回执/单份最终结果走 MQ,取消对外业务查询/补传和分散通话事件。不提供配置HTTP→MQ回退,也不恢复其它业务HTTP通道。SaaS须分发 management 已批准的唯一 SIP 版本,management 仍为唯一编辑/审批面。该本地语义按第三方契约及版本化 Schema/示例/hash 冻结,Mock C 是本地门禁,不等待外部签收;真实切换仍需另行授权。 -- 新HTTP配置字段阶段草案见 `docs/contracts/config-read-fields-v0.1-proposal.md` 及同目录 `config-read-v0.1.schema.json`/mock示例;截图只证实UI含义,英文响应键为项目自定义,绝非SaaS现网接口已确认字段。用户新增任务排除日期、线路时段等未见截图项按项目需求设计;SIP传输/鉴权/注册及额度未知不能猜默认值。Schema/Mock 校验可满足项目内 C,但不代表 SaaS/management 已发布或真实兼容;真实响应、审批来源、摘要和 Agent/Asterisk 实际加载仍须单独验证。当前唯一权威运行契约不因草案变化。 +- 新HTTP配置字段项目内 SIP 新版见 `docs/contracts/config-read-fields-v0.2-proposal.md`、`config-read-v0.2.schema.json`/mock示例;任务/额度仍参照 v0.1;截图只证实UI含义,英文响应键为项目自定义,绝非SaaS现网接口已确认字段。用户新增任务排除日期、线路时段等未见截图项按项目需求设计;SIP传输/鉴权/注册及额度未知不能猜默认值。Schema/Mock 校验可满足项目内 C,但不代表 SaaS/management 已发布或真实兼容;真实响应、审批来源和 Agent/Asterisk 实际加载仍须单独验证。当前唯一权威运行契约不因草案变化。 - **每个Dispatcher必须有独立、全局唯一且不重复的ID及独立接收Topic/队列**;指定D的任务/现行MQ配置结果/上传结果不能由其它D抢收,也不能广播后仅靠正文过滤;新目标只读HTTP配置由该D UUID+SECRETKEY获取且须核对任务归属。身份与tenant/Agent/Cell ID、dispatcher_epoch分开;现行合同保留租户独立队列及原值tenant_key,完整新路由长度预算须重验。具体ID生成/持久化、Topic/绑定、消息字段/关联/错误/期限须随W01新版本冻结,不凭本文给旧严格Schema添加字段。 - **v0.4 任务发现与队列所有权硬边界**:SaaS 独占创建、维护、退役每 D 的独立控制队列与每任务任务队列及绑定;Dispatcher 仅消费,不能自行建队、绑定或删除。本地 RabbitMQ 无 `configure` 权限 Mock 已通过,真实兼容性未验证。新加入或重启 D 先 `GET /internal/v1/dispatcher/tasks?mode=snapshot` 逐页取得同一 `snapshot_id`/`watermark` 的完整清单,全部校验后一次 SQLite 事务提交;独立控制队列积压处理完成前不开任务准入。运行期 `GET .../tasks?after=<内存游标>` 仅发现归属变更,每页持久提交后推进内存游标;重启不恢复旧 v0.3 持久事件游标,分页或持久化失败关新准入、停任务消费,MQ 控制及结果恢复照常处理。HTTP 的偶发状态与 MQ 已应用状态冲突则关准入,不让发现页覆盖已持久的 pause/stop;只有 MQ resume 经单任务新鲜状态确认才恢复原积压。stop 挂断、排空后回自身控制回执,未接纳旧外呼静默 ACK、不拨号、不回逐条结果,也不删 SaaS 任务队列。历史 `task.control` 与 `call.execute` 不因消息年龄过期,外呼仍在接纳和拨号前独立检查任务/白名单/时段/授权/额度及通话时限,未来 `issued_at` 不提前接纳。保留原值 tenant_key、租户额度和未知占用;跨 D 份额仍待外部冻结。v0.1–v0.3 发现证据仅作历史,v0.4 本地证据见 `docs/evidence/dispatcher-v04-local-acceptance.md`;不能证明真实 SaaS/management、多 D 或生产可用。 - **OSS相关配置存于Dispatcher配置文件,Agent向Dispatcher领取临时上传TOKEN后直传OSS,不保存长期凭据;SaaS不再下发OSS配置/TOKEN。** D复用官方SDK提供受限TOKEN/目标信息,配置缺失/无效明确失败;不在样例、源码、日志或证据中保存实际密钥/完整TOKEN。过期只允许A显式向D重新申请,不自动续期或向SaaS申请TOKEN;精确配置格式/TOKEN形态/UploadGrant映射另行核验,不猜字段。 @@ -178,9 +178,9 @@ - `tenant_key` 原值一对一绑定,不清洗、编码或截断;旧布局224个UTF-8字节预算不能在加入D身份后直接照搬,W01须冻结并验证完整routing key/queue预算及分隔符/通配符边界;超限停止发布并保留源任务。 - 持久 inbox 后 ACK;状态与 outbox 同事务;confirm 不等于 SaaS 应用收讫。重复投递、未知执行和恢复不能触发重复拨号。 - 配额覆盖所有 Cell/实例及未知占用;租约过期不自动释放不明通话。**现行 v2** 控制 CAS 为 expected_task_revision,pause 与 stop 的 drain/hangup 区分;paused 可按新授权恢复,stopped 不可恢复。现行整体补传仅 call_id/source_command_id;下一版对外查询/补传目标取消但严格合同/代码未改。最后发起许可、权限和屏障须故障注入。 -- **用户已批准的项目内精简契约目标(不能混写现行 v2;外部签收不阻塞本地 C)**:`call.execute.payload` 仅 `task_id/callee`,外呼信封身份仍用于防止重拨;路由/主叫/智能体版本与 `ring_timeout_ms/max_call_duration_ms` 均从已批准的任务配置取得并持久绑定。`task.control` 的 pause/resume/stop 均不带 `command_id` 或 `expected_task_revision`,不设计控制去重,但 D 必须回 task/action/status 处理结果;乱序、控制重投/回执丢失与 stop 后 resume 的本地判定写入第三方契约并由 Mock 测试;外部签收不是本地阻塞项,不偷偷重引入 CAS/去重掩盖。任务配置新增明确 `caller_profile_id`,按allowed_trunk_ids顺序选首个时段/额度/加载/主叫均匹配的线路,选后固定、不自动换线重拨;有效通话上限取任务与已授权AI两者较小值。旧running配置/发现不得解除已持久的paused/stopped,resume须强制最新任务核验,stopped同ID不可逆。拟定配置响应不再包含 `agent.content_sha256`;SIP 快照/录音 checksum 为另有用途的字段,不误删。参见 `docs/plan-config-read-v0.1.md` 和 `docs/thirds/第三方对接事件与请求消费顺序_v0.1.md`。 +- **用户已批准的项目内精简契约目标(不能混写现行 v2;外部签收不阻塞本地 C)**:`call.execute.payload` 仅 `task_id/callee`,外呼信封身份仍用于防止重拨;路由/主叫/智能体版本与 `ring_timeout_ms/max_call_duration_ms` 均从已批准的任务配置取得并持久绑定。`task.control` 的 pause/resume/stop 均不带 `command_id` 或 `expected_task_revision`,不设计控制去重,但 D 必须回 task/action/status 处理结果;乱序、控制重投/回执丢失与 stop 后 resume 的本地判定写入第三方契约并由 Mock 测试;外部签收不是本地阻塞项,不偷偷重引入 CAS/去重掩盖。任务配置新增明确 `caller_profile_id`,按allowed_trunk_ids顺序选首个时段/额度/加载/主叫均匹配的线路,选后固定、不自动换线重拨;有效通话上限取任务与已授权AI两者较小值。旧running配置/发现不得解除已持久的paused/stopped,resume须强制最新任务核验,stopped同ID不可逆。拟定配置响应不再包含 `agent.content_sha256`;录音 checksum 另有用途,不误删;SIP SaaS 响应不再提供 snapshot_sha256 或静态制品。参见 `docs/plan-config-read-v0.1.md` 和 `docs/thirds/第三方对接事件与请求消费顺序_v0.1.md`。 - management是SIP配置唯一编辑/审批面。**现行 P1**通过批准的版本化静态制品和受控部署入口交付,D核验目标/准入屏障,Agent加载并报告;不要求在线发布控制面。静态交接合同须批准,旧直接写Agent面不能同时启用;成功必须证明精确快照已被Asterisk加载。 -- **本轮项目内 SIP 配置读取目标(现行外部运行未切换)**:management 仍是 SIP 唯一编辑/审批方,SaaS 仅经只读 HTTP 提供该 D 资源分区的获批全量快照;新加入/重启 D 先查询、核验所属 Agent/Asterisk 实际加载的版本/摘要后才消费执行队列。后续约每60秒核对版本,有变化就关执行准入、排空旧活动通话并确认新版已加载;控制MQ照常处理。SaaS/management 分属不同配置系统时须证明SaaS分发的是同一份获批制品;不引入共享业务DB或MQ配置回退。项目内 SIP 读取及准入链路已用隔离 Mock 验证;真实 Agent/Asterisk 加载与 SaaS/management 切换尚未验证。真实 SaaS/management 兼容性及多 D 资源配额份额仍未验证、不属本轮验收,详见 `docs/architecture/Dispatcher有界接纳与控制通道改造计划_v0.1.md` §3.2。 +- **本轮项目内 SIP 配置读取目标(现行外部运行未切换)**:management 仍是 SIP 唯一编辑/审批方,SaaS 仅经只读 HTTP 提供该 D 资源分区的获批全量快照;新加入/重启 D 先查询、核验所属 Agent/Asterisk 实际加载的 SIP 线路 revision 后才消费执行队列;Agent/Asterisk 的部署配置由受控部署确定,不来自 SaaS。后续约每60秒核对版本,有变化就关执行准入、排空旧活动通话并确认新版已加载;控制MQ照常处理。SaaS/management 分属不同配置系统时须证明SaaS分发的是同一份获批线路配置版本;不引入共享业务DB或MQ配置回退。项目内 SIP 读取及准入链路已用隔离 Mock 验证;真实 Agent/Asterisk 加载与 SaaS/management 切换尚未验证。真实 SaaS/management 兼容性及多 D 资源配额份额仍未验证、不属本轮验收,详见 `docs/architecture/Dispatcher有界接纳与控制通道改造计划_v0.1.md` §3.2。 ## 安全和真实验证 diff --git a/contracts/contracts.go b/contracts/contracts.go index d8031a0..b3259b8 100644 --- a/contracts/contracts.go +++ b/contracts/contracts.go @@ -11,13 +11,13 @@ import ( // Files contains pinned upstream schemas and the active local contract versions. // Runtime code never reads a checkout or resolves schema refs online. // -//go:embed upstream local/v0.1 local/v0.3 local/v0.4 +//go:embed upstream local/v0.1 local/v0.2 local/v0.3 local/v0.4 var Files embed.FS const SourceCommit = "v1" func ReadLocal(version, name string) ([]byte, error) { - if (version != "v0.1" && version != "v0.3" && version != "v0.4") || name == "" || path.Base(name) != name || !strings.HasSuffix(name, ".schema.json") { + if (version != "v0.1" && version != "v0.2" && version != "v0.3" && version != "v0.4") || name == "" || path.Base(name) != name || !strings.HasSuffix(name, ".schema.json") { return nil, fmt.Errorf("invalid project-local schema %q/%q", version, name) } return Files.ReadFile(path.Join("local", version, name)) diff --git a/contracts/local/v0.2/config-read-v0.2.schema.json b/contracts/local/v0.2/config-read-v0.2.schema.json new file mode 100644 index 0000000..bd34a59 --- /dev/null +++ b/contracts/local/v0.2/config-read-v0.2.schema.json @@ -0,0 +1,174 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/config-read-v0.2.schema.json", + "title": "Project-local F01 contract: read-only configuration responses; external SaaS compatibility unverified", + "oneOf": [ + {"$ref": "#/$defs/sip_response"}, + {"$ref": "#/$defs/task_response"}, + {"$ref": "#/$defs/tenant_quota_response"}, + {"$ref": "#/$defs/error_response"} + ], + "$defs": { + "sip_response": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "resource", "dispatcher_id", "revision", "approved_at", "trunks"], + "properties": { + "schema_version": {"const": "config-read.v0.2"}, + "resource": {"const": "sip_config"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "revision": {"type": "integer", "minimum": 1}, + "approved_at": {"type": "string", "format": "date-time"}, + "trunks": { + "type": "array", "minItems": 1, "maxItems": 32, + "items": {"$ref": "#/$defs/trunk"} + } + } + }, + "task_response": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "resource", "dispatcher_id", "tenant_id", "tenant_key", "task_id", "task_revision", "status", "max_concurrent_calls", "ring_timeout_ms", "max_call_duration_ms", "route_policy_id", "caller_profile_id", "allowed_trunk_ids", "schedule", "agent"], + "properties": { + "schema_version": {"const": "config-read.v0.1"}, + "resource": {"const": "task_config"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196, "$comment": "Validate <=196 UTF-8 bytes in business logic; preserve the original value and do not place tenant_key in queue/routing names."}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "task_revision": {"type": "integer", "minimum": 1}, + "status": {"enum": ["running", "paused", "stopped", "finished"]}, + "name": {"type": "string", "minLength": 1, "maxLength": 256}, + "group_id": {"type": ["string", "null"], "maxLength": 128}, + "max_concurrent_calls": {"type": "integer", "minimum": 1}, + "ring_timeout_ms": {"type": "integer", "minimum": 1}, + "max_call_duration_ms": {"type": "integer", "minimum": 1}, + "route_policy_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "caller_profile_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "allowed_trunk_ids": {"type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 128}}, + "schedule": {"$ref": "#/$defs/task_schedule"}, + "agent": {"$ref": "#/$defs/agent"} + } + }, + "tenant_quota_response": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "resource", "dispatcher_id", "tenant_id", "tenant_key", "quota_revision", "max_concurrent_calls", "valid_until"], + "properties": { + "schema_version": {"const": "config-read.v0.1"}, + "resource": {"const": "tenant_quota"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196}, + "quota_revision": {"type": "integer", "minimum": 1}, + "max_concurrent_calls": {"type": "integer", "minimum": 0}, + "valid_until": {"type": "string", "format": "date-time"} + }, + "$comment": "Project-local response: assigned share for this Dispatcher, aggregated across all tasks of the tenant. Validate tenant_id/tenant_key mapping and valid_until in business logic." + }, + "error_response": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "resource", "error"], + "properties": { + "schema_version": {"const": "config-read.v0.1"}, + "resource": {"const": "error"}, + "error": { + "type": "object", "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": {"enum": ["invalid_request", "unauthorized", "dispatcher_not_authorized", "resource_not_found", "tenant_quota_unavailable", "service_unavailable"]}, + "message": {"type": "string", "minLength": 1, "maxLength": 256} + } + } + } + }, + "dispatcher_id": { + "type": "string", "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "trunk": { + "type": "object", "additionalProperties": false, + "required": ["trunk_id", "provider_id", "egress_pool_id", "codec", "dial_prefix", "enabled", "server_host", "server_port", "transport", "auth_mode", "registration_required", "max_concurrent_calls", "caller_profiles", "schedule"], + "properties": { + "trunk_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "provider_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "egress_pool_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "codec": {"const": "PCMA"}, + "dial_prefix": {"type": "string", "maxLength": 32}, + "enabled": {"type": "boolean"}, + "server_host": {"type": "string", "minLength": 1, "maxLength": 255}, + "server_port": {"type": "integer", "minimum": 1, "maximum": 65535}, + "transport": {"enum": ["udp", "tcp", "tls", null]}, + "auth_mode": {"enum": ["ip", "digest", "none", null]}, + "registration_required": {"type": ["boolean", "null"]}, + "max_concurrent_calls": {"type": ["integer", "null"], "minimum": 1}, + "caller_profiles": { + "type": "array", "minItems": 1, "maxItems": 32, + "items": { + "type": "object", "additionalProperties": false, + "required": ["caller_profile_id", "caller_id"], + "properties": { + "caller_profile_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "caller_id": {"type": "string", "minLength": 1, "maxLength": 64} + } + } + }, + "schedule": {"$ref": "#/$defs/weekly_schedule"} + } + }, + "agent": { + "type": "object", "additionalProperties": false, + "required": ["agent_version_id", "authorization_id", "authorization_expires_at", "config"], + "properties": { + "agent_version_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "authorization_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "authorization_expires_at": {"type": "string", "format": "date-time"}, + "config": {"$ref": "https://go-sip.local/contracts/v1/ai-config.schema.json"} + } + }, + "task_schedule": { + "type": "object", "additionalProperties": false, + "required": ["time_zone", "starts_at", "ends_at", "weekly_windows", "excluded_dates"], + "properties": { + "time_zone": {"const": "Asia/Shanghai"}, + "starts_at": {"type": ["string", "null"], "format": "date-time"}, + "ends_at": {"type": ["string", "null"], "format": "date-time"}, + "weekly_windows": {"$ref": "#/$defs/weekly_windows"}, + "excluded_dates": { + "type": "array", "uniqueItems": true, + "items": {"type": "string", "format": "date"} + } + } + }, + "weekly_schedule": { + "type": "object", "additionalProperties": false, + "required": ["time_zone", "weekly_windows"], + "properties": { + "time_zone": {"const": "Asia/Shanghai"}, + "weekly_windows": {"$ref": "#/$defs/weekly_windows"} + } + }, + "weekly_windows": { + "type": "object", "additionalProperties": false, + "required": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"], + "properties": { + "monday": {"$ref": "#/$defs/windows"}, + "tuesday": {"$ref": "#/$defs/windows"}, + "wednesday": {"$ref": "#/$defs/windows"}, + "thursday": {"$ref": "#/$defs/windows"}, + "friday": {"$ref": "#/$defs/windows"}, + "saturday": {"$ref": "#/$defs/windows"}, + "sunday": {"$ref": "#/$defs/windows"} + } + }, + "windows": {"type": "array", "items": {"$ref": "#/$defs/window"}, "$comment": "Each window is left-closed/right-open, start < end, and windows within a day must not overlap. Cross-midnight windows are split across two weekdays; 24:00 is allowed only as end."}, + "window": { + "type": "object", "additionalProperties": false, + "required": ["start", "end"], + "properties": { + "start": {"type": "string", "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$"}, + "end": {"type": "string", "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$"} + } + } + } +} diff --git a/docs/contracts/config-read-fields-v0.2-proposal.md b/docs/contracts/config-read-fields-v0.2-proposal.md new file mode 100644 index 0000000..649007f --- /dev/null +++ b/docs/contracts/config-read-fields-v0.2-proposal.md @@ -0,0 +1,100 @@ +# 只读配置与租户额度接口:任务、智能体、SIP 字段与返回结构 v0.1(项目内 F01 规范) + +**状态:项目内 F01 字段规范;不是 SaaS 已有接口/实际 JSON,也不是外部发布契约。** 本地字段由本文件与[第三方对接契约](../thirds/第三方对接事件与请求消费顺序_v0.1.md)定义,使用严格 Schema、正反例、来源/hash 和 Mock 验证;不等待 SaaS/management 外部签收即可完成本地 C。截图可见的业务含义映射为**项目定义的字段名**;截图没有但需求明确的结构由本项目设计。即使字段名与现有项目 Schema 或历史 OpenAPI 相同,也**不能**据此声称它是当前 SaaS 页面原有后端键。真实配置发布、拨号或外部切换仍需另行授权和验证。 + +## 1. 来源、交付边界 + +- **P = 页面观察:**[SaaS 截图分析](../references/saas-page-snapshot-analysis.md) §2–4;只证明表单/列表可见,尤其§7的 **MQ 配置建议是已被新 HTTP 方向取代的历史方案**,不作为新合同。 +- **C = 现行外部项目合同:**[AI 配置](../../contracts/upstream/v1/ai-config.schema.json)、[静态 Cell/SIP 制品](../../contracts/upstream/v1/static-cell-artifact.schema.json)及[当前 MQ 消息](../../contracts/upstream/v1/mq.schema.json)。字段语义可复用,但**不是 SaaS 当前 HTTP 响应证据**。任务发现与分页语义以[当前 v0.4 契约](../thirds/v0.4.md)为准;其它历史背景见[第三方对接契约 v0.1](../thirds/第三方对接事件与请求消费顺序_v0.1.md)。旧 [OpenAPI/MQ 只读索引](../references/OpenAPI与MQ字段索引_v0.1.md) 也仅为历史参考。 +- **N = 新项目字段:**任务每周多时段/排除日期、SIP 线路时段、任务与单 D 绑定、缓存/版本/错误返回等,由本提案定义;实际 SaaS 接口不存在已验证响应。 + +路径来源:`/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/task/:task_id`、`/internal/v1/dispatcher/tasks`(含 `?after=`)为用户给定路径;`/internal/v1/dispatcher/tenant/:tenant_id/quota` 与任务路由字段 `route_policy_id`、`caller_profile_id`、`allowed_trunk_ids` 为本地项目定义。它们不是截图/现网接口已验证的响应键;本地 Mock 按本契约验证。 + +交付物:[新版机器可读响应 Schema](config-read-v0.2.schema.json)、[新版 mock SIP 成功示例](examples/config-read-sip-v0.2.json)([旧 Schema](config-read-v0.1.schema.json)/[旧示例](examples/config-read-sip-v0.1.json)保持历史不变)、[mock 任务成功示例](examples/config-read-task-v0.1.json)、[mock 租户额度示例](examples/config-read-tenant-quota-v0.1.json)、[mock 错误响应示例](examples/config-read-error-v0.1.json)及[预期被 Schema 拒绝的非法示例](examples/config-read-invalid-extra-property-v0.1.json)。四条只读 GET 为本地目标:`/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/task/:task_id`、`/internal/v1/dispatcher/tasks?after=` 与 `/internal/v1/dispatcher/tenant/:tenant_id/quota`。均携带 `X-DISPATCHER-id`(D UUID)及 `X-DISPATCHER-SECRET-KEY`(受控密钥),无请求体;真实 SaaS 实现和字段兼容性未验证。呼叫/控制/回执/最终结果走 MQ,目标移除对外 query/replay,不留旧 AI/SIP 配置 MQ 回退。错误状态和 code 按本文件 §2 与第三方对接契约定义并由 Mock 验证;本地 Schema 不冒充外部权威发布物。 + +## 2. 请求与共同响应 + +Dispatcher 仅用 `X-DISPATCHER-id`(全局唯一 UUID)和部署受控的 `X-DISPATCHER-SECRET-KEY` 请求归属资源;不记录密钥或在日志中打印配置提示词。接口只读、无控制副作用;服务端必须核验任务归属。Header 的本地错误约定为 HTTP 401 `unauthorized`、403 `dispatcher_not_authorized`;请求格式错误为 400 `invalid_request`,资源缺失/未归属为 404 `resource_not_found`,租户额度不可用为 503 `tenant_quota_unavailable`,临时服务故障为 503 `service_unavailable`。`GET /internal/v1/dispatcher/tasks` 的启动快照与运行期增量按[当前 v0.4 契约](../thirds/v0.4.md)执行:分页快照不可继续时返回 409 `snapshot_unavailable`,请求无效时返回 400 `invalid_request`/`invalid_page_token`;不沿用历史版 410 `cursor_expired`/`snapshot_expired`。任务发现严格结构见[任务发现 v0.4 Schema](task-discovery-v0.4-proposal.schema.json),不混入本文件的配置响应 Schema。以上仅为本地 Mock/Go 契约,不代表外部 SaaS 状态码。 + +| 请求 | `200` 返回类型 | 何时读取 | 错误处理 | +| --- | --- | --- | --- | +| `GET /internal/v1/dispatcher/sip` | `resource=sip_config`,本 D 的已批准完整 SIP 快照 | 新 D/重启先取齐并核对 Agent/Asterisk 精确加载;运行中约每 60 秒读取完整配置 | 读取失败/到期停止新执行准入,旧活动通话依原快照排空 | +| `GET /internal/v1/dispatcher/task/:task_id` | `resource=task_config`,归属 D 的单任务配置和已授权智能体快照 | 有待接纳呼叫时获取,活跃任务缓存约 60 秒;resume 必须重取最新配置,不能靠旧 running 恢复 | 失败/过期不放行;已接纳执行仍用原快照 | +| `GET /internal/v1/dispatcher/tenant/:tenant_id/quota`(新增路径草案) | `resource=tenant_quota`,分给该 D 的租户并发份额 | 拿到任务 tenant_id 后读取,同租户任务共享,缓存最多约60秒且不超过有效截止 | 缺失/过期/错身份停该租户新准入,额度0不影响stop静默排空 | + +SIP `200` 响应的 `schema_version` 固定 `config-read.v0.2`,其余配置响应继续使用 `config-read.v0.1`,`resource` 区分 SIP、任务及租户额度结构,`dispatcher_id` 必须等于 Header 中的 D。**不使用条件请求、ETag 或 `304`**:到期时重新 GET 完整响应;只有收到、验证并重新确认授权有效后才更新缓存。SaaS 变更到 D 的目标延迟约 60 秒;缓存到期且刷新失败,**不可无限期沿用旧版本发起新呼叫**。MQ 停/暂停不等待这 60 秒。已接纳/已接通呼叫固定自己的快照,不因缓存过期而漂移。 + +非 `200` 返回 `resource=error`、`error.code`、`error.message` 的脱敏 JSON(HTTP 状态与 code 按本节约定;真实 SaaS 是否一致尚未验证),不得吞成旧配置/空任务。下一版 `call.execute.payload` **只有 `task_id` 与 `callee`**;D 从已批准的任务快照读取路由/主叫/智能体版本及任务级 `ring_timeout_ms/max_call_duration_ms`,接纳前持久绑定完整快照,不能从精简命令中猜值或悄悄采用过期缓存。现行严格 MQ Schema 尚未修改。 + +## 3. 任务成功响应:字段与来源 + +响应中的英文键**全部是本项目提议的返回键**,不是从截图抓到的 SaaS JSON。P/C/N 只说明其业务含义的依据: + +| 返回位置 | 类型 / 是否必有 | 含义及来源 | +| --- | --- | --- | +| `dispatcher_id`, `tenant_id`, `tenant_key`, `task_id`, `task_revision` | UUID v4、租户ID、原值租户键、任务ID、正整数;必有 | C:当前命令及每 D/租户身份;N:任务固定归属一个 D,SaaS 必须持久保存 `(tenant_key,task_id)→dispatcher_id`;不得跨 D 投递。tenant_id 与原值 tenant_key 一对一映射,取得任务后按 tenant_id 读取本 D 租户额度,不能从 task_id 猜。`tenant_key` 需另验证 **≤196 UTF-8 字节**及路由段边界,Schema 的字符数不是字节数。 | +| `status` | `running/paused/stopped/finished`;必有 | P:页面可见启停状态;N:面向 D 的状态枚举是本项目暂定,不承诺与页面/实际 API 状态值同名。停/暂停需配合 MQ 控制屏障,不能只靠缓存。 | +| `name`, `group_id` | 提供时分别为非空字符串、字符串或 `null`;可缺省 | P:任务名称/所属分组。显示信息不参与拨号许可;`null` 与空字符串不混同。 | +| `max_concurrent_calls`, `ring_timeout_ms`, `max_call_duration_ms` | 正整数、正整数毫秒、正整数毫秒;必有 | P:任务并发;N:振铃及最长通话时间是**任务配置**,所有新接纳呼叫由同一获准任务快照取得,不由逐呼命令任意覆盖。通话有效上限取任务 max_call_duration_ms 与已授权 AI conversation.max_duration_ms 较小值,执行侧/AI控制器一致且不改原快照;并发另受租户份额、供应商/Cell/AI约束;截图里的“5”不是默认值。 | +| `route_policy_id`, `caller_profile_id`, `allowed_trunk_ids[]` | 路由标识、明确主叫引用、有序候选线路数组;必有 | N:route_policy_id标识本任务规则,不另引入未定义查询;按候选顺序选首个已加载、时段/额度有效且支持此主叫引用的线路,无匹配不接纳。主叫不默认取首个,线路/主叫选择后持久绑定,拨号失败/未知不自动换线。 | +| `schedule.time_zone`, `starts_at`, `ends_at` | 固定 `Asia/Shanghai`、带偏移时间或 `null`;必有 | P:任务起止时间;N:三字段格式/无值约定。时间约束与星期段、排除日期、线路时段**同时成立**。 | +| `schedule.weekly_windows` | 七个星期键各为可空的时间段数组;必有 | P:周一至周日网格、同日多个时段;N:`{start,end}` 用 `HH:MM`,左闭右开,`start < end`,跨午夜拆到次日,不假定 UI 已有这个 JSON 结构。空数组=当天不可呼。 | +| `schedule.excluded_dates[]` | 不重复的 `YYYY-MM-DD` 数组;必有,可为空 | N:用户新增的可选多日期排除(截图**没有**此字段)。日期按 Asia/Shanghai 判断并优先于星期段;真实日期、时段排序/重叠与边界须在业务校验中处理。 | +| `agent.agent_version_id`, `config` | ID、严格 AI 对象;必有 | P:任务选择 AI 模型/智能体;C:现有不可变 `agent_version_id` 和[AI Schema](../../contracts/upstream/v1/ai-config.schema.json)。不再返回 `content_sha256`;同版本不得变内容的检查应基于版本绑定及本地持久快照,不能悄悄接受漂移。`config` 原样遵守该现有 Schema,不把截图未覆盖参数偷塞 `metadata`。 | +| `agent.authorization_id`, `authorization_expires_at` | 非空 ID、带偏移时间;必有 | C:当前 MQ AI 授权含关联 ID 和有效期;N:嵌入任务 HTTP 响应的承载位置新设计,过期不可新接纳。 | + +`agent.config` 当前可承载的**运行字段**:`mode`;ASR 的 `provider_ref/model/language/interim/input/timeout_ms`;LLM 的 `provider_ref/credential_ref/model/temperature/max_tokens/timeout_ms`;`prompt.text/allowed_variables/max_bytes`;TTS 的 `provider_ref/credential_ref/model/voice/speed/format/timeout_ms`;`conversation.opening/allow_interrupt/silence_timeout_ms/max_duration_ms/max_turns/sentence_max_chars/max_pending_audio_chunks` 等以**现有 AI Schema 本身为准**。`asr_only` 与 `full_ai` 两种模式均须按 Schema/SDK 能力分别校验;提示词不得出现在示例或日志中的真实用户文本。`agent.agent_version_id` 必须等于 `agent.config.agent_version_id`;授权身份及截止时间必须单独核验,不通过额外 `content_sha256` 字段证明授权。 + +新call.execute没有逐呼variables来源;需要未提供变量的提示词必须拒绝或在F01先补获批来源,不能填空继续执行。状态来源按总计划§3.3:stopped不可逆、paused只能经最新有效resume解除;任务缓存和tasks增量的旧running不能解锁。 + +### 3.1 页面观察但不作为 D 运行字段 + +| 原页面可见项 | 归属判断 / 暂不返回原因 | +| --- | --- | +| 智能体名称/描述、草稿/提交、文字/语音/线路测试 | SaaS 管理页面元数据/测试入口,不能代替 `agent_version_id` 的已发布运行快照。 | +| 提示词编辑器工具、独立开场白、挂断触发/结束语 | `prompt.text` 与 `conversation.opening` 可映射当前合同;挂断条件与结束语尚无当前严格 AI 字段,不能猜到通话控制里。 | +| ASR 页面 PCM/Opus/AAC/OGG/WAV、标点、去语气词、单句时长 | 当前合同支持的输入为 `pcm_s16le` 等已定义值;其它编码及三个开关/时长需先验证媒体和 SDK,并修订 GAP-09/新合同。 | +| LLM 对话模式、Top-P、重复惩罚、Top-K、随机种子、思考/流式开关 | 模型/温度等已有字段可用;其余没有获批准的运行字段及参数能力 PoC,**不进入 HTTP 的 `agent.config`**,不静默忽略后宣称已生效。 | +| TTS 公共/个人音色、情绪、音调、MP3/WAV 选项、试听 | 已有 `voice/speed/format` 可按实际能力承载;其它参数和试听不直接映射现有 Agent 可执行配置。 | +| 话后分析提示词及 A–F 意向规则 | 页面可见但当前 AI 快照没有对应执行和结果契约;仍由 SaaS 负责或另行定义,不能伪装成外呼 Agent 参数。 | +| 任务拨打顺序/时间间隔、自动重呼及次数/条件、结束动作、黑名单组、备注 | P:页面有这些项;本接口只返回 D **当前已获授权且有实现责任**的准入信息。排序、间隔、名单/运营策略应由 SaaS 明确负责;自动重呼不得伪装成 MQ 重投或 D 的自动再拨。 | +| 导入号码、号码列表、统计、通话记录、意向图表 | 属单次 `call.execute`/SaaS 展示与运营事实,不能一次塞进“任务配置”返回;不能从截图冻结数据页的 API 列名和返回结构。 | + +这些字段**已在字段盘点中固化存在性和缺口**,不是声称 SaaS 已有相应返回键。若用户明确要求其中某项由 D/Agent 执行,先核实上游模型/SDK、增加获批准的严格字段及正反例,不在新接口中以 `metadata` 或 raw JSON 穿透。 + +## 4. SIP 成功响应:字段与来源 + +| 返回位置 | 类型 / 是否必有 | 含义及来源 | +| --- | --- | --- | +| `dispatcher_id`, `revision`, `approved_at` | UUID v4、正整数、带偏移时间;必有 | N:指定 D 的完整获批 SIP 线路版本。每次内容变化递增 revision;相同版本内容漂移及版本倒退必须拒绝。 | +| `trunks[]` | 严格数组;必有 | N:单份获批线路清单;每项含 `trunk_id/provider_id/egress_pool_id/codec/dial_prefix/enabled`、服务端、鉴权、主叫、额度及每周时段。线路 ID 和同线路主叫引用不得重复。前缀只用于本线路,主叫保留原值(可含字母);PCMA 仍须实际线路验证。 | +| `server_host`, `server_port`, `transport` | 主机/端口、`udp/tcp/tls/null`;必有 | N:补足“全量 SIP”需要的实际对端;页面仅显示任务选线路,**没有管理线路完整配置截图**。供应商传输未知时为 `null`,绝不擅自按 UDP 默认发起 real。mock 示例地址非真实供应商。 | +| `auth_mode`, `registration_required` | `ip/digest/none/null`、`true/false/null`;必有 | N:供应商鉴权/注册未知时 `null`,不得把主叫号当 Digest 账号;real 放行前须供应商/management 批准并验证。不返回密码、私钥或真实 TOKEN。 | +| `max_concurrent_calls` | 正整数或 `null`;必有 | N:线路/供应商获批份额,`null` 表示未知(real 必须拒绝新准入),不能拿截图任务“线路数量”猜限额;跨 D 份额总和须受源配额约束。 | +| `caller_profiles[]` | `{caller_profile_id, caller_id}` 数组;必有 | C:旧制品仅有 profile ID;N:全量响应映射 profile→原样主叫标识。From/PAI 具体映射仍待供应商确认;示例主叫是 mock,不是生产号。 | +| `schedule.time_zone`, `weekly_windows` | `Asia/Shanghai`,七天逐日零或多个时段;必有 | N:SIP 线路允许拨打时段(截图未提供),与任务时段相交;无允许段则不能呼叫。时间跨午夜拆到次日,结果还受 Agent 最后拨号门禁约束。 | + +SaaS 只提供 SIP 连接和线路拨号约束,不下发 Agent/Asterisk 的部署参数、静态 Cell 制品、运行模式或全局号码白名单。部署参数及 Cell 身份由本地受控配置核对。management 仍是 SIP 唯一编辑/审批面,SaaS 必须分发同一获批版本。D 从 Agent 实际运行状态核对已加载的 SIP revision;HTTP `200` 及仅收到配置不代表已生效。示例 `transport/auth_mode/registration_required/max_concurrent_calls=null` 是供应商待确认项,**不满足 real 放行**。 + +## 4.1 租户额度响应(项目内新增字段 N,外部未签收) + +| 字段 | 类型/约束 | 业务语义 | +| --- | --- | --- | +| schema_version/resource | config-read.v0.1 / tenant_quota | 项目草案,不是现网版本。 | +| dispatcher_id/tenant_id/tenant_key | D UUID、租户ID、原值租户键,必有 | 与请求、任务、信封一致,SaaS一对一映射;错误不猜值。 | +| quota_revision | 正整数,必有 | 本D租户额度版本,旧版本不覆盖新分配。 | +| max_concurrent_calls | 非负整数,必有 | 本D同租户所有任务共用份额,0禁止新呼叫;不是每任务分别上限。 | +| valid_until | RFC3339时间,必有 | 截止后不得新准入,本地缓存最多约60秒且不得越过此时刻。 | + +D 在同一事务预留租户/任务/线路等占用,未知继续计入;降额不强挂、占用低于新上限才再接新。额度缺失、过期/错身份、刷新失败关闭新准入,不能以任务额度代替。已确认通话终结/执行资源释放即可释放通话额度,不等录音上传或MQ确认;stop静默ACK不需申请通话名额。多D须由SaaS分份额,累计不超过租户总额;本轮只验证单D。 + +## 5. 外部待核事项(不阻塞本地 F01/C) + +1. **外部来源与审批:**真实 SaaS/management 联调前,确认 SaaS 的任务、智能体是该服务的权威配置;management 仍是唯一 SIP 编辑/审批方。证明 SaaS 分发的是 management 已批准的完整 SIP 线路版本,不能出现两份可写配置。此项不阻塞本地 Mock。 +2. **身份和响应:**本地请求头为 `X-DISPATCHER-id` 与 `X-DISPATCHER-SECRET-KEY`;只读取归属 D 的 `/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/tasks`、`/internal/v1/dispatcher/task/:task_id` 和 `/internal/v1/dispatcher/tenant/:tenant_id/quota`,不在日志/示例保存真实密钥。身份校验、状态码与生效时间按本文件和第三方对接契约作为本地规则;真实 SaaS 兼容性未验证。不采用 `ETag`/`304`。 +3. **窗口与版本:**本地缓存成功核验起约 60 秒;SaaS 变更对未接纳呼叫最多约 60 秒延迟,过期重新 GET 完整数据失败就停止新准入,已接纳保留原快照。更新/SIP 加载期间停执行队列,不停控制 MQ;停/暂停不等缓存。没有 `304` 延长授权的通道。跨日窗口、重叠段、当日排除、时间边界及任务与线路交集按本地 Schema/业务测试执行;真实 SaaS 行为未验证。 +4. **一致性:**`agent_version_id` 与 AI 授权一致且有效,同版内容漂移必须拒绝;任务归属/修订/route policy 以已绑定任务快照为准,MQ 命令不得覆盖;`trunks` 中线路 ID/主叫引用唯一;线路 status、主叫、前缀、线路/租户/供应商额度来源和 Agent/Asterisk 实际加载不可依赖 JSON Schema 单独判断。供应商未知传输/鉴权/注册不得默认允许 real。 +5. **消费状态:**pause保留原队列积压,resume最新配置/授权/额度有效才继续消费,无需SaaS重新投递;新版命令不带 `not_after`,积压外呼仍须在接纳和拨号前重新核验时段及授权。stop后未接纳积压静默消费ACK,不拨号、不发逐条回执/最终结果;控制本身与已在途通话结果仍回传,本地计数/错误不静默。状态优先级及例外按总计划§3.3,不加控制去重。 +6. **退出与过渡:**新接口上线前现行 MQ-only/单 D/固定时段/静态 SIP 仍有效。切到新版本后配置只走 HTTP,旧 `ai.config.request/result` 停用,不做 HTTP→MQ 回退;业务 MQ 正常运行。多 D 任务归属/共享额度份额、旧命令/缓存/恢复记录和控制屏障须单独验证;任务结束只删除配置缓存,不删除未决执行与消息事实。 + +**验证状态:**本地 JSON Schema 草案包含 SIP、任务、租户额度 `200` 成功响应、有效错误响应及一个额外字段非法样例;只有 Schema 校验通过的有效示例可作为正例,非法样例必须被拒绝。此离线校验**不能**证明真实 SaaS 接口字段名、management 签收、hash 规范、Agent SDK 映射、SIP 实际加载或任何生产外呼验收。 diff --git a/docs/contracts/config-read-v0.2.schema.json b/docs/contracts/config-read-v0.2.schema.json new file mode 100644 index 0000000..bd34a59 --- /dev/null +++ b/docs/contracts/config-read-v0.2.schema.json @@ -0,0 +1,174 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/config-read-v0.2.schema.json", + "title": "Project-local F01 contract: read-only configuration responses; external SaaS compatibility unverified", + "oneOf": [ + {"$ref": "#/$defs/sip_response"}, + {"$ref": "#/$defs/task_response"}, + {"$ref": "#/$defs/tenant_quota_response"}, + {"$ref": "#/$defs/error_response"} + ], + "$defs": { + "sip_response": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "resource", "dispatcher_id", "revision", "approved_at", "trunks"], + "properties": { + "schema_version": {"const": "config-read.v0.2"}, + "resource": {"const": "sip_config"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "revision": {"type": "integer", "minimum": 1}, + "approved_at": {"type": "string", "format": "date-time"}, + "trunks": { + "type": "array", "minItems": 1, "maxItems": 32, + "items": {"$ref": "#/$defs/trunk"} + } + } + }, + "task_response": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "resource", "dispatcher_id", "tenant_id", "tenant_key", "task_id", "task_revision", "status", "max_concurrent_calls", "ring_timeout_ms", "max_call_duration_ms", "route_policy_id", "caller_profile_id", "allowed_trunk_ids", "schedule", "agent"], + "properties": { + "schema_version": {"const": "config-read.v0.1"}, + "resource": {"const": "task_config"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196, "$comment": "Validate <=196 UTF-8 bytes in business logic; preserve the original value and do not place tenant_key in queue/routing names."}, + "task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"}, + "task_revision": {"type": "integer", "minimum": 1}, + "status": {"enum": ["running", "paused", "stopped", "finished"]}, + "name": {"type": "string", "minLength": 1, "maxLength": 256}, + "group_id": {"type": ["string", "null"], "maxLength": 128}, + "max_concurrent_calls": {"type": "integer", "minimum": 1}, + "ring_timeout_ms": {"type": "integer", "minimum": 1}, + "max_call_duration_ms": {"type": "integer", "minimum": 1}, + "route_policy_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "caller_profile_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "allowed_trunk_ids": {"type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": {"type": "string", "minLength": 1, "maxLength": 128}}, + "schedule": {"$ref": "#/$defs/task_schedule"}, + "agent": {"$ref": "#/$defs/agent"} + } + }, + "tenant_quota_response": { + "type": "object", "additionalProperties": false, + "required": ["schema_version", "resource", "dispatcher_id", "tenant_id", "tenant_key", "quota_revision", "max_concurrent_calls", "valid_until"], + "properties": { + "schema_version": {"const": "config-read.v0.1"}, + "resource": {"const": "tenant_quota"}, + "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, + "tenant_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "tenant_key": {"type": "string", "minLength": 1, "maxLength": 196}, + "quota_revision": {"type": "integer", "minimum": 1}, + "max_concurrent_calls": {"type": "integer", "minimum": 0}, + "valid_until": {"type": "string", "format": "date-time"} + }, + "$comment": "Project-local response: assigned share for this Dispatcher, aggregated across all tasks of the tenant. Validate tenant_id/tenant_key mapping and valid_until in business logic." + }, + "error_response": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "resource", "error"], + "properties": { + "schema_version": {"const": "config-read.v0.1"}, + "resource": {"const": "error"}, + "error": { + "type": "object", "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": {"enum": ["invalid_request", "unauthorized", "dispatcher_not_authorized", "resource_not_found", "tenant_quota_unavailable", "service_unavailable"]}, + "message": {"type": "string", "minLength": 1, "maxLength": 256} + } + } + } + }, + "dispatcher_id": { + "type": "string", "format": "uuid", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + }, + "trunk": { + "type": "object", "additionalProperties": false, + "required": ["trunk_id", "provider_id", "egress_pool_id", "codec", "dial_prefix", "enabled", "server_host", "server_port", "transport", "auth_mode", "registration_required", "max_concurrent_calls", "caller_profiles", "schedule"], + "properties": { + "trunk_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "provider_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "egress_pool_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "codec": {"const": "PCMA"}, + "dial_prefix": {"type": "string", "maxLength": 32}, + "enabled": {"type": "boolean"}, + "server_host": {"type": "string", "minLength": 1, "maxLength": 255}, + "server_port": {"type": "integer", "minimum": 1, "maximum": 65535}, + "transport": {"enum": ["udp", "tcp", "tls", null]}, + "auth_mode": {"enum": ["ip", "digest", "none", null]}, + "registration_required": {"type": ["boolean", "null"]}, + "max_concurrent_calls": {"type": ["integer", "null"], "minimum": 1}, + "caller_profiles": { + "type": "array", "minItems": 1, "maxItems": 32, + "items": { + "type": "object", "additionalProperties": false, + "required": ["caller_profile_id", "caller_id"], + "properties": { + "caller_profile_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "caller_id": {"type": "string", "minLength": 1, "maxLength": 64} + } + } + }, + "schedule": {"$ref": "#/$defs/weekly_schedule"} + } + }, + "agent": { + "type": "object", "additionalProperties": false, + "required": ["agent_version_id", "authorization_id", "authorization_expires_at", "config"], + "properties": { + "agent_version_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "authorization_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "authorization_expires_at": {"type": "string", "format": "date-time"}, + "config": {"$ref": "https://go-sip.local/contracts/v1/ai-config.schema.json"} + } + }, + "task_schedule": { + "type": "object", "additionalProperties": false, + "required": ["time_zone", "starts_at", "ends_at", "weekly_windows", "excluded_dates"], + "properties": { + "time_zone": {"const": "Asia/Shanghai"}, + "starts_at": {"type": ["string", "null"], "format": "date-time"}, + "ends_at": {"type": ["string", "null"], "format": "date-time"}, + "weekly_windows": {"$ref": "#/$defs/weekly_windows"}, + "excluded_dates": { + "type": "array", "uniqueItems": true, + "items": {"type": "string", "format": "date"} + } + } + }, + "weekly_schedule": { + "type": "object", "additionalProperties": false, + "required": ["time_zone", "weekly_windows"], + "properties": { + "time_zone": {"const": "Asia/Shanghai"}, + "weekly_windows": {"$ref": "#/$defs/weekly_windows"} + } + }, + "weekly_windows": { + "type": "object", "additionalProperties": false, + "required": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"], + "properties": { + "monday": {"$ref": "#/$defs/windows"}, + "tuesday": {"$ref": "#/$defs/windows"}, + "wednesday": {"$ref": "#/$defs/windows"}, + "thursday": {"$ref": "#/$defs/windows"}, + "friday": {"$ref": "#/$defs/windows"}, + "saturday": {"$ref": "#/$defs/windows"}, + "sunday": {"$ref": "#/$defs/windows"} + } + }, + "windows": {"type": "array", "items": {"$ref": "#/$defs/window"}, "$comment": "Each window is left-closed/right-open, start < end, and windows within a day must not overlap. Cross-midnight windows are split across two weekdays; 24:00 is allowed only as end."}, + "window": { + "type": "object", "additionalProperties": false, + "required": ["start", "end"], + "properties": { + "start": {"type": "string", "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$"}, + "end": {"type": "string", "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$"} + } + } + } +} diff --git a/docs/contracts/examples/config-read-sip-invalid-artifact-v0.2.json b/docs/contracts/examples/config-read-sip-invalid-artifact-v0.2.json new file mode 100644 index 0000000..45f4ead --- /dev/null +++ b/docs/contracts/examples/config-read-sip-invalid-artifact-v0.2.json @@ -0,0 +1,38 @@ +{ + "schema_version": "config-read.v0.2", + "resource": "sip_config", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "revision": 1, + "approved_at": "2026-09-21T08:00:00+08:00", + "artifact": {}, + "trunks": [{ + "trunk_id": "trunk-mock", + "provider_id": "provider-mock", + "egress_pool_id": "egress-mock", + "codec": "PCMA", + "dial_prefix": "", + "enabled": true, + "server_host": "sip.example.invalid", + "server_port": 5060, + "transport": null, + "auth_mode": null, + "registration_required": null, + "max_concurrent_calls": null, + "caller_profiles": [{ + "caller_profile_id": "caller-profile-mock", + "caller_id": "BD00000000" + }], + "schedule": { + "time_zone": "Asia/Shanghai", + "weekly_windows": { + "monday": [{"start": "09:00", "end": "20:00"}], + "tuesday": [{"start": "09:00", "end": "20:00"}], + "wednesday": [{"start": "09:00", "end": "20:00"}], + "thursday": [{"start": "09:00", "end": "20:00"}], + "friday": [{"start": "09:00", "end": "20:00"}], + "saturday": [], + "sunday": [] + } + } + }] +} diff --git a/docs/contracts/examples/config-read-sip-v0.2.json b/docs/contracts/examples/config-read-sip-v0.2.json new file mode 100644 index 0000000..816e702 --- /dev/null +++ b/docs/contracts/examples/config-read-sip-v0.2.json @@ -0,0 +1,37 @@ +{ + "schema_version": "config-read.v0.2", + "resource": "sip_config", + "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", + "revision": 1, + "approved_at": "2026-09-21T08:00:00+08:00", + "trunks": [{ + "trunk_id": "trunk-mock", + "provider_id": "provider-mock", + "egress_pool_id": "egress-mock", + "codec": "PCMA", + "dial_prefix": "", + "enabled": true, + "server_host": "sip.example.invalid", + "server_port": 5060, + "transport": null, + "auth_mode": null, + "registration_required": null, + "max_concurrent_calls": null, + "caller_profiles": [{ + "caller_profile_id": "caller-profile-mock", + "caller_id": "BD00000000" + }], + "schedule": { + "time_zone": "Asia/Shanghai", + "weekly_windows": { + "monday": [{"start": "09:00", "end": "20:00"}], + "tuesday": [{"start": "09:00", "end": "20:00"}], + "wednesday": [{"start": "09:00", "end": "20:00"}], + "thursday": [{"start": "09:00", "end": "20:00"}], + "friday": [{"start": "09:00", "end": "20:00"}], + "saturday": [], + "sunday": [] + } + } + }] +} diff --git a/docs/contracts/local-contract-manifest-v0.2.json b/docs/contracts/local-contract-manifest-v0.2.json index 0396983..5c2a3c6 100644 --- a/docs/contracts/local-contract-manifest-v0.2.json +++ b/docs/contracts/local-contract-manifest-v0.2.json @@ -1,7 +1,7 @@ { "manifest_version": "local-contract-manifest.v0.2", "hash_algorithm": "SHA-256", - "source": {"path": "docs/thirds/v0.2.md", "sha256": "5358eaaecf944704975feab9150dcae8224ea89247a1086c68965ea46c253e31"}, + "source": {"path": "docs/thirds/v0.2.md", "sha256": "bda3af43816b03e0b8ff164998e083282cfe95b1040deee7e62d5cf7c179912b"}, "artifacts": [ {"path": "docs/contracts/config-read-v0.1.schema.json", "sha256": "043dd26a9033b7fd6401c2fc918a1fec2ecbaac0bd59cbba6da72d02125b8184"}, {"path": "docs/contracts/command-next-v0.1-proposal.schema.json", "sha256": "fcd3ec1d56baa69a22fac76363a533e252658fb3b7a4fe7020f4322d965716de"}, diff --git a/docs/contracts/local-contract-manifest-v0.5.json b/docs/contracts/local-contract-manifest-v0.5.json new file mode 100644 index 0000000..328d794 --- /dev/null +++ b/docs/contracts/local-contract-manifest-v0.5.json @@ -0,0 +1,11 @@ +{ + "manifest_version": "local-contract-manifest.v0.5", + "hash_algorithm": "SHA-256", + "source": {"path": "docs/contracts/config-read-fields-v0.2-proposal.md", "sha256": "037dc0540d7f89f2f13624239989e2e2ffb2b5eab2770e7c46f37a51eae41afa"}, + "artifacts": [ + {"path": "docs/thirds/v0.2.md", "sha256": "bda3af43816b03e0b8ff164998e083282cfe95b1040deee7e62d5cf7c179912b"}, + {"path": "docs/contracts/config-read-v0.2.schema.json", "sha256": "e91809bc90c1913ed094d5a275df03c6dfb0bdaf54f624b8633780c269935809"}, + {"path": "docs/contracts/examples/config-read-sip-v0.2.json", "sha256": "b7a159e70b882eaab7a83f36d969627176e0be1a7f0b0d58e2f4ce8448860790"}, + {"path": "docs/contracts/examples/config-read-sip-invalid-artifact-v0.2.json", "sha256": "403370acfceeda428873600adabc759f90faed2bfc766380ece3bd998bc90980"} + ] +} diff --git a/docs/plan-config-read-v0.1.md b/docs/plan-config-read-v0.1.md index 17b4aa9..37cf7bf 100644 --- a/docs/plan-config-read-v0.1.md +++ b/docs/plan-config-read-v0.1.md @@ -34,7 +34,7 @@ - **最小路由约定(本地项目契约,F01 Schema/Mock 验证):**`allowed_trunk_ids` 按列表顺序作为候选优先级,选首条满足时段、已加载状态、供应商/线路额度且支持任务 `caller_profile_id` 的线路;无满足项不接纳。`caller_profile_id` 是新增任务级明确引用,必须命中选中线路的获批主叫,不默认取第一个主叫。`route_policy_id` 只标识这份任务路由,不依赖另一个未定义的查询接口。选定线路/主叫后持久绑定;拨号失败或未知不自动换线重拨。 - `ring_timeout_ms` 是任务级振铃上限;通话有效上限是任务 `max_call_duration_ms` 与已授权 AI `conversation.max_duration_ms` 的**较小值**,控制器和执行侧均使用同一值。两值不相同必须记录来源/有效值,不改写智能体原快照或悄悄放宽任何上限。没有逐呼 `variables` 的新合同不提供联系人变量来源;需要未提供变量的提示词不得以空字符串代替继续执行,须在 F01 明确静态提示词范围或补齐获批来源。 - SIP/任务配置约 60 秒缓存到期重取完整 `200`,不使用 ETag/304;过期/失败拒绝新准入,不影响既有执行快照。配置内 `status=running` **不是恢复许可**,状态按 §3.3 独立处理。 -- F02 项目内 Agent 状态校验:使用受部署配置约束的 `agent_id/cell_id` 查询 Agent status;`applied_configs` 必须恰有一条 `kind="sip"`,且 `state="applied"`、十进制 `revision` 与 `config_sha256` 分别精确匹配获批 SIP artifact revision/SHA-256、`observed_at_unix_ms>0`,才可读取新配置/准入。缺失、重复、身份不符或匹配失败均 fail-closed。此为本地 Agent 协议规则,不是 SaaS/management 外部确认;Mock 状态不能证明真实 Asterisk 已加载。 +- F02 项目内 Agent 状态校验:使用受部署配置约束的 `agent_id/cell_id` 查询 Agent status;`applied_configs` 必须恰有一条 `kind="sip"`,且 `state="applied"`、十进制 `revision` 精确匹配获批 SIP 线路版本、`observed_at_unix_ms>0`,才可读取新配置/准入。缺失、重复、身份不符或匹配失败均 fail-closed。此为本地 Agent 协议规则,不是 SaaS/management 外部确认;Mock 状态不能证明真实 Asterisk 已加载。 ### 3.2 租户额度 diff --git a/docs/thirds/v0.2.md b/docs/thirds/v0.2.md index 164cc91..0597654 100644 --- a/docs/thirds/v0.2.md +++ b/docs/thirds/v0.2.md @@ -1,4 +1,4 @@ -- 本文是新版项目内字段与状态语义的说明;v0.1 文档及证据仅留历史,不作为新版运行契约。四条拟定 GET 的响应字段、路径和外部兼容性尚待真实 SaaS 核对。机器校验文件为[配置读取 Schema](../contracts/config-read-v0.1.schema.json)、[任务发现 Schema](../contracts/task-discovery-v0.2-proposal.schema.json)、[命令/控制 Schema](../contracts/command-next-v0.1-proposal.schema.json)、[最终结果 Schema](../contracts/call-result-v0.1-proposal.schema.json),队列拓扑为[MQ 拓扑文件](../contracts/mq-topology-v0.1-proposal.json)。它们均为项目内版本,不修改现行上游 v1 契约。 +- 本文是新版项目内字段与状态语义的说明;v0.1 文档及证据仅留历史,不作为新版运行契约。四条拟定 GET 的响应字段、路径和外部兼容性尚待真实 SaaS 核对。SIP 新版机器校验为[配置读取 v0.2 Schema](../contracts/config-read-v0.2.schema.json)(旧 SIP v0.1 Schema 保留历史);其他配置字段仍沿用原合同。机器校验文件另有[原配置读取 Schema](../contracts/config-read-v0.1.schema.json)、[任务发现 Schema](../contracts/task-discovery-v0.2-proposal.schema.json)、[命令/控制 Schema](../contracts/command-next-v0.1-proposal.schema.json)、[最终结果 Schema](../contracts/call-result-v0.1-proposal.schema.json),队列拓扑为[MQ 拓扑文件](../contracts/mq-topology-v0.1-proposal.json)。它们均为项目内版本,不修改现行上游 v1 契约。 - 每个 SaaS↔D JSON 消息体按 UTF-8 序列化后最多 **8,388,608 bytes**。超限结果保留在持久 outbox,标记 `blocked_payload_too_large` 并记录 event_id/字节数/SHA-256;不发布、不截断、不拆分、不丢弃,需由显式版本变更处理。 - 对已接纳且预期有录音的通话,上传阶段最迟在 `call.ended_at + 15m` 收口;OSS 成功发送 `uploaded`,明确 PUT 失败立即发送 `unavailable`,仍无确定结果则到期发送 `unavailable`。授权固定 15 分钟;每个录音/upload_id/object_key 组合最多一次 PUT。授权在 PUT 前过期时,Agent 可在上述截止时间内显式向 D 为同一 upload_id/object_key 重新申请授权;不自动续期或创建第二份资产,任何已发起 PUT 都不得重试。确认未产生录音的 `not_created` 立即收口。`call.result` 只生成一次,MQ 重投复用原 event_id。 - 控制按 task 串行处理,并核对最新任务状态:pause 只接受权威状态 `paused`,resume 只接受 `running` 且本地未 stopped,stop 只接受 `stopped`;乱序/不一致时保持准入关闭并拒绝,stopped 不可逆。控制本身无 command_id/expected revision,不按消息身份去重,重复动作只保持状态幂等。 @@ -59,7 +59,7 @@ SaaS 创建并绑定: 四个 GET 均**无请求 JSON 体**,统一使用 `X-DISPATCHER-ID`(全局唯一 D UUID)和 `X-DISPATCHER-SECRET-KEY`(HEADER 头统一转小写判定匹配)。本地 Mock 使用隔离测试凭据;真实 SaaS 地址、认证实现及轮换未验证,不阻塞本地开发。SIP 返回本 D 全量;单任务按路径中的 `task_id` 查询,SaaS 必须核对归属 D 与原值 `tenant_key`,任务发现则按 D 返回归属清单。**不使用 ETag、If-None-Match 或 304**:任务与 SIP 配置约 60 秒缓存到期时 GET 完整 200 响应,失败只停新准入,已接纳执行保持绑定快照;MQ 控制不等待配置缓存。`tasks` 的每 30 秒增量轮询另见 §2.5。 -### 2.1 SIP 配置:200,返回本 D 的完整获批快照 +### 2.1 SIP 配置:200,返回本 D 的完整获批线路(项目内新版;其他 v0.2 历史内容不变) 请求(地址/Header 仍待 SaaS 实现确认): @@ -74,54 +74,19 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> ```json { - "schema_version": "config-read.v0.1", + "schema_version": "config-read.v0.2", "resource": "sip_config", "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", "revision": 1, - "snapshot_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "approved_at": "2026-09-21T08:00:00+08:00", - "artifact": { - "artifact_id": "artifact-cell-mock-1", - "source_release": "mock-release-1", - "source_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "approval_reference": "mock-approval-1", - "cell_id": "cell-mock", - "revision": 1, - "config_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "mode": "mock", - "allowed_targets": [ - "15003164745", - "15830461047" - ], - "trunks": [ - { - "trunk_id": "trunk-mock", - "provider_id": "provider-mock", - "egress_pool_id": "egress-mock", - "codec": "PCMA", - "caller_profile_ids": [ - "caller-profile-mock" - ], - "dial_prefix": "", - "enabled": true, - "sip_endpoint_ref": "sip-endpoint-mock", - "credential_ref": null, - "media_profile_id": "pcma-8k" - } - ], - "media_profiles": { - "pcma-8k": { - "format": "alaw", - "sample_rate_hz": 8000, - "channels": 1, - "payload_type": 8 - } - }, - "load_evidence": null - }, - "trunk_details": [ + "trunks": [ { "trunk_id": "trunk-mock", + "provider_id": "provider-mock", + "egress_pool_id": "egress-mock", + "codec": "PCMA", + "dial_prefix": "", + "enabled": true, "server_host": "sip.example.invalid", "server_port": 5060, "transport": null, @@ -179,11 +144,10 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> **字段说明/消费动作:** > Cell:一个外呼应用Asterisk实例(当前阶段不扩展复杂分布式,写死单实例数据,仅填充Trunk 数据列表,后期根据需求调整分布式架构); > Trunk: 一条外呼线路; -- `schema_version/resource`:草案版本 `config-read.v0.1`、资源 `sip_config`;`dispatcher_id`:只能与发起请求的 D 相同。 -- `revision/snapshot_sha256/approved_at`:整份获批快照的修订、摘要、批准时间;摘要生成规则和版本来源仍待双方确定,示例摘要仅为占位值。 **(预留,暂不验证)** -- `artifact`:静态 Cell 制品;`artifact_id/source_release/source_digest/approval_reference` 标识制品、来源版本/摘要和批准引用;`cell_id/revision/config_sha256` 标识执行单元及制品版本;`mode` 是 mock/real 范围;`allowed_targets` 是允许的原始号码;`load_evidence` 为可空加载证据。`trunks[]` 中 `trunk_id/provider_id/egress_pool_id` 定义线路、供应商、出口;`codec` 为 PCMA;`caller_profile_ids` 为主叫引用;`dial_prefix` 仅本线路前缀;`enabled` 是否启用;`sip_endpoint_ref/credential_ref/media_profile_id` 为连接、凭据和媒体配置引用,不传实际密码。`media_profiles` 下 `format/sample_rate_hz/channels/payload_type` 定义媒体格式。 -- `trunk_details[]`:每项的 `trunk_id` 必须与 `artifact.trunks[]` 一一对应;`server_host/server_port` 为 SIP 服务端;`transport/auth_mode/registration_required` 是传输、认证和注册方式;`max_concurrent_calls` 是分配到该 D 的线路额度;`null` 代表未知,不可用于真实外呼。`caller_profiles[].caller_profile_id/caller_id` 给出主叫引用/原始标识(如含 `BD`),不可清洗成纯数字。 -- `schedule.time_zone/weekly_windows`:Asia/Shanghai 的周一至周日多时段,`start/end` 是每日左闭右开时间;空日禁止外呼。D 校验获批版本与线路完整性并自行核对生效,不能仅凭 HTTP `200` 就认为已加载。 +- `schema_version/resource`:草案版本 `config-read.v0.2`、资源 `sip_config`;`dispatcher_id`:只能与发起请求的 D 相同。 +- `revision/approved_at`:本 D 获批的完整 SIP 线路版本和批准时间。每次更新均须递增 revision;同版本内容不得变化。D 持久核验同版内容不漂移,不接纳倒退版本。 +- `trunks[]`:唯一的线路列表;`trunk_id/provider_id/egress_pool_id` 定义线路、供应商及出口,`codec` 为 PCMA,`dial_prefix` 只用于该线路,`enabled` 控制线路是否可用。`server_host/server_port/transport/auth_mode/registration_required` 为连接方式;未知传输、鉴权、注册或额度不得放行真实外呼。`max_concurrent_calls` 为分配给本 D 的线路额度;`caller_profiles[].caller_profile_id/caller_id` 为主叫引用及原值(可含 `BD`)。`schedule` 是 Asia/Shanghai 每周逐日多时段、左闭右开,空日不可呼。线路 ID 和主叫引用不能重复。 +- SaaS 只提供 SIP 连接及线路拨号约束,不下发 Agent/Asterisk 的 Cell、ARI、媒体、录音、部署制品、全局号码白名单或运行模式。白名单和部署设置由本地受控配置承担。D 只用一个 SIP `revision` 与 Agent 回报的**实际已加载 SIP 版本**核对;加载/核验失败关闭新准入,不以 HTTP `200` 或仅收到配置冒充 Asterisk 已加载。部署时确定的 Agent/Cell 身份由本地核对,不由 SaaS 控制。 ### 2.2 任务配置:200,ASR + LLM + TTS 模式 diff --git a/internal/configread/client.go b/internal/configread/client.go index ede7646..7d767dc 100644 --- a/internal/configread/client.go +++ b/internal/configread/client.go @@ -38,10 +38,6 @@ type Snapshot struct { TenantID string TenantKey string SIPRevision int64 - SIPSnapshotSHA256 string - SIPCellID string - SIPArtifactRevision int64 - SIPArtifactConfigSHA256 string TaskRevision int64 TaskStatus string TaskMaxConcurrentCalls int64 @@ -122,10 +118,23 @@ func (c *Client) ReadTask(ctx context.Context, taskID, tenantID string) (Snapsho if err := json.Unmarshal(sipBody, &sip); err != nil { return Snapshot{}, fmt.Errorf("decode SIP configuration identity: %w", err) } - if sip.Resource != resourceSIPConfig || sip.DispatcherID != c.dispatcherID || sip.Revision <= 0 || sip.SnapshotSHA256 == "" || - sip.Artifact.CellID == "" || sip.Artifact.Revision <= 0 || sip.Artifact.ConfigSHA256 == "" { + if sip.Resource != resourceSIPConfig || sip.DispatcherID != c.dispatcherID || sip.Revision <= 0 { return Snapshot{}, errors.New("SIP configuration identity or revision does not match the request") } + seenTrunks := make(map[string]bool, len(sip.Trunks)) + for _, trunk := range sip.Trunks { + if seenTrunks[trunk.TrunkID] { + return Snapshot{}, fmt.Errorf("SIP configuration repeats trunk %q", trunk.TrunkID) + } + seenTrunks[trunk.TrunkID] = true + seenCallers := make(map[string]bool, len(trunk.CallerProfiles)) + for _, caller := range trunk.CallerProfiles { + if seenCallers[caller.CallerProfileID] { + return Snapshot{}, fmt.Errorf("SIP trunk %q repeats caller profile %q", trunk.TrunkID, caller.CallerProfileID) + } + seenCallers[caller.CallerProfileID] = true + } + } taskPath := configReadPath + "/task/" + url.PathEscape(taskID) taskBody, err := c.getConfig(ctx, taskPath) @@ -165,10 +174,8 @@ func (c *Client) ReadTask(ctx context.Context, taskID, tenantID string) (Snapsho return Snapshot{ TaskID: taskID, TenantID: tenantID, TenantKey: task.TenantKey, - SIPRevision: sip.Revision, SIPSnapshotSHA256: sip.SnapshotSHA256, - SIPCellID: sip.Artifact.CellID, SIPArtifactRevision: sip.Artifact.Revision, - SIPArtifactConfigSHA256: sip.Artifact.ConfigSHA256, - TaskRevision: task.TaskRevision, TaskStatus: task.Status, + SIPRevision: sip.Revision, + TaskRevision: task.TaskRevision, TaskStatus: task.Status, TaskMaxConcurrentCalls: int64(task.MaxConcurrentCalls), TaskRingTimeoutMS: int64(task.RingTimeoutMS), TaskMaxCallDurationMS: int64(task.MaxCallDurationMS), TaskRoutePolicyID: task.RoutePolicyID, TaskCallerProfileID: task.CallerProfileID, TaskAllowedTrunkIDs: append([]string(nil), task.AllowedTrunkIDs...), @@ -307,15 +314,15 @@ func responseErrorCode(body []byte) string { } type sipConfigResponse struct { - Resource string `json:"resource"` - DispatcherID string `json:"dispatcher_id"` - Revision int64 `json:"revision"` - SnapshotSHA256 string `json:"snapshot_sha256"` - Artifact struct { - CellID string `json:"cell_id"` - Revision int64 `json:"revision"` - ConfigSHA256 string `json:"config_sha256"` - } `json:"artifact"` + Resource string `json:"resource"` + DispatcherID string `json:"dispatcher_id"` + Revision int64 `json:"revision"` + Trunks []struct { + TrunkID string `json:"trunk_id"` + CallerProfiles []struct { + CallerProfileID string `json:"caller_profile_id"` + } `json:"caller_profiles"` + } `json:"trunks"` } type taskConfigResponse struct { diff --git a/internal/configread/client_test.go b/internal/configread/client_test.go index 971aafb..a8c2705 100644 --- a/internal/configread/client_test.go +++ b/internal/configread/client_test.go @@ -67,6 +67,38 @@ func TestClientReadTaskRequestsAndValidatesThreeConfigEndpoints(t *testing.T) { } } +func TestClientReadTaskRejectsDuplicateSIPTrunksAndCallers(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(map[string]any) + }{ + {"duplicate trunk", func(sip map[string]any) { + trunks := sip["trunks"].([]any) + sip["trunks"] = append(trunks, trunks[0]) + }}, + {"duplicate caller", func(sip map[string]any) { + trunk := sip["trunks"].([]any)[0].(map[string]any) + profiles := trunk["caller_profiles"].([]any) + trunk["caller_profiles"] = append(profiles, profiles[0]) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + fixtures := validConfigFixtures(t) + var sip map[string]any + if err := json.Unmarshal(fixtures[configReadPath+"/sip"], &sip); err != nil { + t.Fatal(err) + } + tc.mutate(sip) + fixtures[configReadPath+"/sip"] = marshalDiscoveryResponse(t, sip) + server := configFixtureServer(t, fixtures) + defer server.Close() + if _, err := newMockClient(t, server).ReadTask(context.Background(), mockTaskID, mockTenantID); err == nil { + t.Fatal("duplicate SIP configuration identity accepted") + } + }) + } +} + func TestClientReadTaskRejectsSchemaInvalidResponse(t *testing.T) { fixtures := validConfigFixtures(t) fixtures[configReadPath+"/sip"] = readConfigFixture(t, "config-read-invalid-extra-property-v0.1.json") @@ -139,7 +171,7 @@ func newMockClient(t *testing.T, server *httptest.Server) *Client { func validConfigFixtures(t *testing.T) map[string][]byte { t.Helper() return map[string][]byte{ - configReadPath + "/sip": readConfigFixture(t, "config-read-sip-v0.1.json"), + configReadPath + "/sip": readConfigFixture(t, "config-read-sip-v0.2.json"), configReadPath + "/tasks": readConfigFixture(t, "task-discovery-snapshot-v0.2.json"), configReadPath + "/task/" + mockTaskID: readConfigFixture(t, "config-read-task-v0.1.json"), configReadPath + "/tenant/" + mockTenantID + "/quota": readConfigFixture(t, "config-read-tenant-quota-v0.1.json"), diff --git a/internal/contract/contract.go b/internal/contract/contract.go index e1e0195..5b1b674 100644 --- a/internal/contract/contract.go +++ b/internal/contract/contract.go @@ -69,7 +69,7 @@ func ValidateEvent(raw []byte) error { } func ValidateLocalConfigRead(raw []byte) error { - return validateLocalSchema("config-read-v0.1.schema.json", raw) + return validateLocalSchema("config-read-v0.2.schema.json", raw) } func ValidateLocalTaskDiscoveryV04(raw []byte) error { diff --git a/internal/contract/schema.go b/internal/contract/schema.go index 84cd689..fbaf0c3 100644 --- a/internal/contract/schema.go +++ b/internal/contract/schema.go @@ -62,6 +62,8 @@ func localSchemaVersion(name string) string { switch name { case "config-read-v0.1.schema.json", "command-next-v0.1-proposal.schema.json", "call-result-v0.1-proposal.schema.json", "local-mock-recording-failure-v0.1.schema.json": return "v0.1" + case "config-read-v0.2.schema.json": + return "v0.2" case "task-discovery-v0.3-proposal.schema.json": return "v0.3" case "task-discovery-v0.4-proposal.schema.json", "task-control-v0.4-proposal.schema.json", "call-execute-v0.4-proposal.schema.json": diff --git a/internal/dispatcher/agent.go b/internal/dispatcher/agent.go index ba7e589..cd7a79a 100644 --- a/internal/dispatcher/agent.go +++ b/internal/dispatcher/agent.go @@ -2,8 +2,6 @@ package dispatcher import ( "context" - "crypto/sha256" - "encoding/hex" "errors" "fmt" "strconv" @@ -37,22 +35,18 @@ func (v *AgentSIPConfigVerifier) VerifyAppliedSIPConfig(ctx context.Context, sna if v == nil || v.Probe == nil || v.AgentID == "" || v.CellID == "" { return errors.New("Agent status probe, agent ID, and configured cell ID are required") } - if snapshot.SIPCellID != v.CellID { - return fmt.Errorf("SIP config targets cell %q, configured cell is %q", snapshot.SIPCellID, v.CellID) - } status, err := v.Probe.Probe(ctx, v.AgentID, v.CellID) if err != nil { return fmt.Errorf("probe Agent applied SIP config: %w", err) } - return verifyAppliedSIPConfigStatus(status, v.AgentID, snapshot) + return verifyAppliedSIPConfigStatus(status, v.AgentID, v.CellID, snapshot) } -func verifyAppliedSIPConfigStatus(status *agentv1.AgentStatus, agentID string, snapshot configread.Snapshot) error { - digest, digestErr := hex.DecodeString(snapshot.SIPArtifactConfigSHA256) - if snapshot.SIPCellID == "" || snapshot.SIPArtifactRevision <= 0 || digestErr != nil || len(digest) != sha256.Size { - return errors.New("SIP config artifact identity is incomplete") +func verifyAppliedSIPConfigStatus(status *agentv1.AgentStatus, agentID, cellID string, snapshot configread.Snapshot) error { + if snapshot.SIPRevision <= 0 { + return errors.New("SIP config revision is missing") } - if status == nil || status.AgentId != agentID || status.CellId != snapshot.SIPCellID || status.BootId == "" { + if status == nil || status.AgentId != agentID || status.CellId != cellID || status.BootId == "" { return errors.New("Agent status identity or boot ID is invalid") } var appliedSIP *agentv1.AppliedConfig @@ -68,9 +62,9 @@ func verifyAppliedSIPConfigStatus(status *agentv1.AgentStatus, agentID string, s if appliedSIP == nil || appliedSIP.State != AppliedConfigStateApplied || appliedSIP.ObservedAtUnixMs <= 0 { return errors.New("Agent has not reported one observed, applied SIP config") } - revision := strconv.FormatInt(snapshot.SIPArtifactRevision, 10) - if appliedSIP.Revision != revision || appliedSIP.ConfigSha256 != snapshot.SIPArtifactConfigSHA256 { - return fmt.Errorf("Agent applied SIP config does not match revision %s and expected SHA-256", revision) + revision := strconv.FormatInt(snapshot.SIPRevision, 10) + if appliedSIP.Revision != revision { + return fmt.Errorf("Agent applied SIP config does not match revision %s", revision) } return nil } diff --git a/internal/dispatcher/config_read.go b/internal/dispatcher/config_read.go index c8a5bda..02037b3 100644 --- a/internal/dispatcher/config_read.go +++ b/internal/dispatcher/config_read.go @@ -102,10 +102,6 @@ func (d *Dispatcher) LoadProjectConfig(ctx context.Context, client *configread.C for key, current := range d.projectConfigs { current.SIP = append(current.SIP[:0], snapshot.SIP...) current.SIPRevision = snapshot.SIPRevision - current.SIPSnapshotSHA256 = snapshot.SIPSnapshotSHA256 - current.SIPCellID = snapshot.SIPCellID - current.SIPArtifactRevision = snapshot.SIPArtifactRevision - current.SIPArtifactConfigSHA256 = snapshot.SIPArtifactConfigSHA256 current.ExpiresAt = minTime(current.ExpiresAt, sharedExpiry) if key.tenantID == snapshot.TenantID { current.TenantQuota = append(current.TenantQuota[:0], snapshot.TenantQuota...) diff --git a/internal/dispatcher/dial_policy.go b/internal/dispatcher/dial_policy.go index 97730aa..9a95d08 100644 --- a/internal/dispatcher/dial_policy.go +++ b/internal/dispatcher/dial_policy.go @@ -40,16 +40,8 @@ type dialTaskConfig struct { } type dialSIPConfig struct { - Artifact struct { - Mode string `json:"mode"` - AllowedTargets []string `json:"allowed_targets"` - Trunks []struct { - TrunkID string `json:"trunk_id"` - Enabled bool `json:"enabled"` - CallerProfileIDs []string `json:"caller_profile_ids"` - } `json:"trunks"` - } `json:"artifact"` - TrunkDetails []struct { + Trunks []struct { + Enabled bool `json:"enabled"` TrunkID string `json:"trunk_id"` MaxConcurrentCalls *int64 `json:"max_concurrent_calls"` CallerProfiles []struct { @@ -57,7 +49,7 @@ type dialSIPConfig struct { CallerID string `json:"caller_id"` } `json:"caller_profiles"` Schedule callwindow.WeeklySchedule `json:"schedule"` - } `json:"trunk_details"` + } `json:"trunks"` } // A line is chosen once at admission, in the task's listed order. The second @@ -74,8 +66,8 @@ func checkSelectedDialPolicy(snapshot configread.Snapshot, callee, trunkID strin } func evaluateDialPolicy(snapshot configread.Snapshot, callee, selectedTrunk string, at time.Time, newAdmission bool) (dialDecision, error) { - // This project-wide allowlist is never inferred from an artifact or rewritten - // using a provider prefix. Provider targets further restrict this set. + // The project-wide allowlist is local, never inferred from SaaS or + // rewritten using a provider prefix. if callee != "15003164745" && callee != "15830461047" { return dialDecision{}, errors.New("callee is not on the approved outbound whitelist") } @@ -96,9 +88,6 @@ func evaluateDialPolicy(snapshot configread.Snapshot, callee, selectedTrunk stri if task.Status != "running" || len(task.AllowedTrunkIDs) == 0 || task.CallerProfileID == "" { return dialDecision{}, errors.New("task is not authorized for dialing") } - if !slices.Contains(sip.Artifact.AllowedTargets, callee) { - return dialDecision{}, errors.New("callee is not in the approved SIP artifact") - } if task.RingTimeoutMS <= 0 || task.MaxCallDurationMS <= 0 || task.Agent.Config.Conversation.MaxDurationMS <= 0 { return dialDecision{}, errors.New("task or AI call duration is missing") } @@ -118,25 +107,14 @@ func evaluateDialPolicy(snapshot configread.Snapshot, callee, selectedTrunk stri if selectedTrunk != "" && candidate != selectedTrunk { continue } - var artifactTrunk *struct { - TrunkID string `json:"trunk_id"` - Enabled bool `json:"enabled"` - CallerProfileIDs []string `json:"caller_profile_ids"` - } - for i := range sip.Artifact.Trunks { - if sip.Artifact.Trunks[i].TrunkID == candidate { - artifactTrunk = &sip.Artifact.Trunks[i] - break - } - } - if artifactTrunk == nil || !artifactTrunk.Enabled || !slices.Contains(artifactTrunk.CallerProfileIDs, task.CallerProfileID) { - lastReason = fmt.Errorf("trunk %s is disabled or has no approved caller profile", candidate) - continue - } - for _, detail := range sip.TrunkDetails { + for _, detail := range sip.Trunks { if detail.TrunkID != candidate { continue } + if !detail.Enabled { + lastReason = fmt.Errorf("trunk %s is disabled", candidate) + break + } if detail.MaxConcurrentCalls == nil || *detail.MaxConcurrentCalls <= 0 { lastReason = fmt.Errorf("trunk %s has no authorized capacity", candidate) break diff --git a/internal/dispatcher/dial_policy_test.go b/internal/dispatcher/dial_policy_test.go index afaea7f..9946544 100644 --- a/internal/dispatcher/dial_policy_test.go +++ b/internal/dispatcher/dial_policy_test.go @@ -20,13 +20,13 @@ func policyAt(t *testing.T, value string) time.Time { func policySnapshot(t *testing.T) configread.Snapshot { t.Helper() - sip := localConfigFixture(t, "config-read-sip-v0.1.json") + sip := localConfigFixture(t, "config-read-sip-v0.2.json") var document map[string]any if err := json.Unmarshal(sip, &document); err != nil { t.Fatal(err) } // Explicit local Mock capacity; the documented null remains an unknown real limit. - document["trunk_details"].([]any)[0].(map[string]any)["max_concurrent_calls"] = 3 + document["trunks"].([]any)[0].(map[string]any)["max_concurrent_calls"] = 3 sip, err := json.Marshal(document) if err != nil { t.Fatal(err) @@ -77,11 +77,11 @@ func TestSelectDialPolicyRequiresKnownCapacityCallerAndWhitelist(t *testing.T) { name string alter func() }{ - {"unknown trunk capacity", func() { sip["trunk_details"].([]any)[0].(map[string]any)["max_concurrent_calls"] = nil }}, + {"unknown trunk capacity", func() { sip["trunks"].([]any)[0].(map[string]any)["max_concurrent_calls"] = nil }}, {"no matching caller", func() { - sip["artifact"].(map[string]any)["trunks"].([]any)[0].(map[string]any)["caller_profile_ids"] = []string{"other"} + sip["trunks"].([]any)[0].(map[string]any)["caller_profiles"].([]any)[0].(map[string]any)["caller_profile_id"] = "other" }}, - {"trunk disabled", func() { sip["artifact"].(map[string]any)["trunks"].([]any)[0].(map[string]any)["enabled"] = false }}, + {"trunk disabled", func() { sip["trunks"].([]any)[0].(map[string]any)["enabled"] = false }}, } { t.Run(tc.name, func(t *testing.T) { var item map[string]any @@ -121,21 +121,13 @@ func TestSelectDialPolicyUsesFirstEligibleTrunkWithoutPostSelectionSwitch(t *tes if err := json.Unmarshal(snapshot.SIP, &sip); err != nil { t.Fatal(err) } - artifact := sip["artifact"].(map[string]any) - first := artifact["trunks"].([]any)[0].(map[string]any) + first := sip["trunks"].([]any)[0].(map[string]any) second := make(map[string]any) for k, v := range first { second[k] = v } second["trunk_id"] = "trunk-second" - artifact["trunks"] = append(artifact["trunks"].([]any), second) - details := sip["trunk_details"].([]any)[0].(map[string]any) - secondDetails := make(map[string]any) - for k, v := range details { - secondDetails[k] = v - } - secondDetails["trunk_id"] = "trunk-second" - sip["trunk_details"] = append(sip["trunk_details"].([]any), secondDetails) + sip["trunks"] = append(sip["trunks"].([]any), second) snapshot.TaskAllowedTrunkIDs = []string{"trunk-mock", "trunk-second"} var task map[string]any if err := json.Unmarshal(snapshot.Task, &task); err != nil { @@ -143,9 +135,9 @@ func TestSelectDialPolicyUsesFirstEligibleTrunkWithoutPostSelectionSwitch(t *tes } task["allowed_trunk_ids"] = snapshot.TaskAllowedTrunkIDs snapshot.Task, _ = json.Marshal(task) - firstWindow := details["schedule"].(map[string]any)["weekly_windows"].(map[string]any) + firstWindow := first["schedule"].(map[string]any)["weekly_windows"].(map[string]any) firstWindow["monday"] = []any{map[string]any{"start": "09:00", "end": "10:00"}} - secondDetails["schedule"] = map[string]any{"time_zone": "Asia/Shanghai", "weekly_windows": map[string]any{ + second["schedule"] = map[string]any{"time_zone": "Asia/Shanghai", "weekly_windows": map[string]any{ "monday": []any{map[string]any{"start": "10:00", "end": "11:00"}}, "tuesday": []any{}, "wednesday": []any{}, "thursday": []any{}, "friday": []any{}, "saturday": []any{}, "sunday": []any{}, }} diff --git a/internal/dispatcher/local_v01_integration_test.go b/internal/dispatcher/local_v01_integration_test.go index 37e7480..e69b5e3 100644 --- a/internal/dispatcher/local_v01_integration_test.go +++ b/internal/dispatcher/local_v01_integration_test.go @@ -35,7 +35,7 @@ func TestLocalDispatcherMockSaaSEndToEndWithOutboxRecovery(t *testing.T) { // The published example intentionally leaves supplier capacity unknown; // only this isolated Mock grants a positive, explicit trunk limit. fixtures := map[string][]byte{ - "/internal/v1/dispatcher/sip": localConfigFixture(t, "config-read-sip-v0.1.json"), + "/internal/v1/dispatcher/sip": localConfigFixture(t, "config-read-sip-v0.2.json"), "/internal/v1/dispatcher/task/" + localTestTaskID: readLocalFixture(t, "config-read-task-v0.1.json"), "/internal/v1/dispatcher/tenant/" + localTestTenantID + "/quota": readLocalFixture(t, "config-read-tenant-quota-v0.1.json"), } diff --git a/internal/dispatcher/local_v01_test.go b/internal/dispatcher/local_v01_test.go index acf93e1..5f2996a 100644 --- a/internal/dispatcher/local_v01_test.go +++ b/internal/dispatcher/local_v01_test.go @@ -541,7 +541,7 @@ func localExecuteTaskCommandBody(t *testing.T, commandID, taskID string, referen func newLocalV01TestDispatcher(t *testing.T, now time.Time) (*Dispatcher, *store.Store, *httptest.Server) { t.Helper() mux := http.NewServeMux() - mux.HandleFunc("/internal/v1/dispatcher/sip", localConfigResponse(string(localConfigFixture(t, "config-read-sip-v0.1.json")))) + mux.HandleFunc("/internal/v1/dispatcher/sip", localConfigResponse(string(localConfigFixture(t, "config-read-sip-v0.2.json")))) mux.HandleFunc("/internal/v1/dispatcher/tasks", func(w http.ResponseWriter, r *http.Request) { after := r.URL.Query().Get("after") if after == "" || after == "0" { @@ -639,7 +639,7 @@ func newLocalV01MultiTaskConfigServer(t *testing.T, taskIDs []string, quotaRevis t.Fatal(err) } mux := http.NewServeMux() - mux.HandleFunc("/internal/v1/dispatcher/sip", localConfigResponse(string(localConfigFixture(t, "config-read-sip-v0.1.json")))) + mux.HandleFunc("/internal/v1/dispatcher/sip", localConfigResponse(string(localConfigFixture(t, "config-read-sip-v0.2.json")))) mux.HandleFunc("/internal/v1/dispatcher/tasks", localConfigResponse(string(discoveryBody))) mux.HandleFunc("/internal/v1/dispatcher/task/", func(w http.ResponseWriter, r *http.Request) { taskID := r.URL.Path[len("/internal/v1/dispatcher/task/"):] @@ -657,13 +657,13 @@ func newLocalV01MultiTaskConfigServer(t *testing.T, taskIDs []string, quotaRevis func localTestSIPConfigVerifier() SIPConfigVerifier { return SIPConfigVerifierFunc(func(_ context.Context, snapshot configread.Snapshot) error { status := &agentv1.AgentStatus{ - AgentId: "agent-mock", CellId: snapshot.SIPCellID, BootId: "boot-mock", + AgentId: "agent-mock", CellId: "cell-mock", BootId: "boot-mock", AppliedConfigs: []*agentv1.AppliedConfig{{ - Kind: AppliedConfigKindSIP, Revision: fmt.Sprint(snapshot.SIPArtifactRevision), - ConfigSha256: snapshot.SIPArtifactConfigSHA256, State: AppliedConfigStateApplied, ObservedAtUnixMs: 1, + Kind: AppliedConfigKindSIP, Revision: fmt.Sprint(snapshot.SIPRevision), + State: AppliedConfigStateApplied, ObservedAtUnixMs: 1, }}, } - return verifyAppliedSIPConfigStatus(status, "agent-mock", snapshot) + return verifyAppliedSIPConfigStatus(status, "agent-mock", "cell-mock", snapshot) }) } @@ -673,14 +673,14 @@ func localConfigFixture(t *testing.T, name string) []byte { if err != nil { t.Fatal(err) } - if name == "config-read-sip-v0.1.json" { + if name == "config-read-sip-v0.2.json" { var response map[string]any if err := json.Unmarshal(body, &response); err != nil { t.Fatal(err) } // Only the isolated Mock is granted explicit capacity; the documented null // remains unknown and must not authorize a real supplier trunk. - response["trunk_details"].([]any)[0].(map[string]any)["max_concurrent_calls"] = 3 + response["trunks"].([]any)[0].(map[string]any)["max_concurrent_calls"] = 3 body, err = json.Marshal(response) if err != nil { t.Fatal(err) diff --git a/internal/dispatcher/sip_applied_config_test.go b/internal/dispatcher/sip_applied_config_test.go index 00aade8..0f1d9ca 100644 --- a/internal/dispatcher/sip_applied_config_test.go +++ b/internal/dispatcher/sip_applied_config_test.go @@ -22,17 +22,14 @@ func (p *staticAgentStatusProbe) Probe(_ context.Context, agentID, cellID string return p.status, p.err } -func TestAgentSIPConfigVerifierRequiresExactAppliedArtifact(t *testing.T) { - snapshot := configread.Snapshot{ - SIPCellID: "cell-a", SIPArtifactRevision: 7, - SIPArtifactConfigSHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - } +func TestAgentSIPConfigVerifierRequiresExactAppliedRevision(t *testing.T) { + snapshot := configread.Snapshot{SIPRevision: 7} newStatus := func() *agentv1.AgentStatus { return &agentv1.AgentStatus{ - AgentId: "agent-a", CellId: snapshot.SIPCellID, BootId: "boot-a", + AgentId: "agent-a", CellId: "cell-a", BootId: "boot-a", AppliedConfigs: []*agentv1.AppliedConfig{{ - Kind: AppliedConfigKindSIP, Revision: fmt.Sprint(snapshot.SIPArtifactRevision), - ConfigSha256: snapshot.SIPArtifactConfigSHA256, State: AppliedConfigStateApplied, + Kind: AppliedConfigKindSIP, Revision: fmt.Sprint(snapshot.SIPRevision), + State: AppliedConfigStateApplied, ObservedAtUnixMs: 1000, }}, } @@ -47,7 +44,7 @@ func TestAgentSIPConfigVerifierRequiresExactAppliedArtifact(t *testing.T) { } wrongCellProbe := &staticAgentStatusProbe{status: newStatus()} wrongCellVerifier := &AgentSIPConfigVerifier{Probe: wrongCellProbe, AgentID: "agent-a", CellID: "cell-b"} - if err := wrongCellVerifier.VerifyAppliedSIPConfig(context.Background(), snapshot); err == nil || wrongCellProbe.gotCellID != "" { + if err := wrongCellVerifier.VerifyAppliedSIPConfig(context.Background(), snapshot); err == nil || wrongCellProbe.gotCellID != "cell-b" { t.Fatal("SIP config for a different configured cell was probed or accepted") } var nilVerifier *AgentSIPConfigVerifier @@ -69,7 +66,6 @@ func TestAgentSIPConfigVerifierRequiresExactAppliedArtifact(t *testing.T) { {name: "wrong kind", status: newStatus()}, {name: "not applied", status: newStatus()}, {name: "wrong revision", status: newStatus()}, - {name: "wrong digest", status: newStatus()}, {name: "missing observation time", status: newStatus()}, {name: "probe failure", status: newStatus(), err: errors.New("status unavailable")}, } @@ -83,15 +79,13 @@ func TestAgentSIPConfigVerifierRequiresExactAppliedArtifact(t *testing.T) { case "missing boot ID": c.status.BootId = "" case "multiple SIP applied configs": - c.status.AppliedConfigs = append(c.status.AppliedConfigs, &agentv1.AppliedConfig{Kind: AppliedConfigKindSIP, Revision: "7", ConfigSha256: snapshot.SIPArtifactConfigSHA256, State: AppliedConfigStateApplied, ObservedAtUnixMs: 2000}) + c.status.AppliedConfigs = append(c.status.AppliedConfigs, &agentv1.AppliedConfig{Kind: AppliedConfigKindSIP, Revision: "7", State: AppliedConfigStateApplied, ObservedAtUnixMs: 2000}) case "wrong kind": c.status.AppliedConfigs[0].Kind = "media" case "not applied": c.status.AppliedConfigs[0].State = "pending" case "wrong revision": c.status.AppliedConfigs[0].Revision = "6" - case "wrong digest": - c.status.AppliedConfigs[0].ConfigSha256 = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" case "missing observation time": c.status.AppliedConfigs[0].ObservedAtUnixMs = 0 } diff --git a/scripts/validate-local-contracts.py b/scripts/validate-local-contracts.py index 23e53e3..33568fa 100644 --- a/scripts/validate-local-contracts.py +++ b/scripts/validate-local-contracts.py @@ -85,7 +85,7 @@ for index, match in enumerate(re.finditer(r"```json\s*(.*?)\s*```", doc, re.DOTA positive_counts[name] += 1 for path in sorted(EXAMPLES.glob("config-read-*.json")): - if "invalid" in path.name or path == STATUS_FIXTURE: + if "invalid" in path.name or path == STATUS_FIXTURE or path.name.endswith("-v0.2.json"): continue validators["config-read"].validate(load_json(path)) positive_counts["config-read"] += 1 @@ -198,7 +198,8 @@ def validate_manifest(): required_artifacts.update( path.relative_to(ROOT).as_posix() for path in EXAMPLES.glob("*.json") - if path.name.startswith(("config-read-", "command-next-", "call-result-")) + if (path.name.startswith(("config-read-", "command-next-", "call-result-")) + and not path.name.endswith("-v0.2.json")) or (path.name.startswith("task-discovery-") and path.name.endswith("-v0.1.json")) ) artifact_paths = [entry.get("path") for entry in artifacts if isinstance(entry, dict)] @@ -274,7 +275,8 @@ for example in ("snapshot", "changes"): raise SystemExit(f"v0.2 {field} accepted more than 256 items") proposal_doc = ROOT / "docs/thirds/v0.2.md" -proposal_versions = {**versions, "task-discovery.v0.2-proposal": "task-discovery-v0.2"} +proposal_versions = {**versions, "config-read.v0.2": "config-read-sip-v0.2", "task-discovery.v0.2-proposal": "task-discovery-v0.2"} +validators["config-read-sip-v0.2"] = Draft202012Validator(load_json(ROOT / "docs/contracts/config-read-v0.2.schema.json"), registry=registry, format_checker=FormatChecker()) for match in re.finditer(r"```json\s*(.*?)\s*```", proposal_doc.read_text(encoding="utf-8"), re.DOTALL): sample = json.loads(match.group(1)) if isinstance(sample, dict) and sample.get("schema_version") in proposal_versions: @@ -306,6 +308,42 @@ for entry in [source, *artifacts]: raise SystemExit(f"v0.2 manifest SHA-256 mismatch: {relative}") print(f"Task discovery v0.2 proposal (historical): positive={proposal_positive}, negative={proposal_negative}, manifest files={1 + len(artifacts)}") +# The SIP-only v0.2 response replaces the old static-artifact SIP payload; +# the other config-read resources remain on v0.1. +sip_schema_path = ROOT / "docs/contracts/config-read-v0.2.schema.json" +if (ROOT / "contracts/local/v0.2/config-read-v0.2.schema.json").read_bytes() != sip_schema_path.read_bytes(): + raise SystemExit("embedded SIP config-read v0.2 schema differs from project source") +sip_schema = load_json(sip_schema_path) +Draft202012Validator.check_schema(sip_schema) +sip_validator = validators["config-read-sip-v0.2"] +sip_positive = EXAMPLES / "config-read-sip-v0.2.json" +sip_negative = EXAMPLES / "config-read-sip-invalid-artifact-v0.2.json" +sip_validator.validate(load_json(sip_positive)) +try: + sip_validator.validate(load_json(sip_negative)) +except ValidationError: + pass +else: + raise SystemExit("old artifact payload unexpectedly accepted by SIP v0.2") +sip_manifest = load_json(ROOT / "docs/contracts/local-contract-manifest-v0.5.json") +expected_sip_files = { + "docs/thirds/v0.2.md", "docs/contracts/config-read-v0.2.schema.json", + "docs/contracts/examples/config-read-sip-v0.2.json", + "docs/contracts/examples/config-read-sip-invalid-artifact-v0.2.json", +} +if (sip_manifest.get("manifest_version") != "local-contract-manifest.v0.5" + or sip_manifest.get("hash_algorithm") != "SHA-256" + or sip_manifest.get("source", {}).get("path") != "docs/contracts/config-read-fields-v0.2-proposal.md"): + raise SystemExit("invalid SIP v0.2 manifest header") +sip_entries = [sip_manifest["source"], *sip_manifest.get("artifacts", [])] +if {entry.get("path") for entry in sip_entries[1:]} != expected_sip_files or len(sip_entries) != 5: + raise SystemExit("SIP v0.2 manifest artifact set mismatch") +for entry in sip_entries: + path = ROOT / entry["path"] + if hashlib.sha256(path.read_bytes()).hexdigest() != entry.get("sha256"): + raise SystemExit(f"SIP v0.2 manifest SHA-256 mismatch: {entry['path']}") +print("SIP config-read v0.2: positive=1, negative=1, manifest files=5") + # v0.3 replaces the v0.2 runtime path; v0.2 files above remain historical evidence. event_doc = ROOT / "docs/thirds/v0.3.md" event_schema_path = ROOT / "docs/contracts/task-discovery-v0.3-proposal.schema.json"