From f5c2d6a92036a579e0b070beb1d9381b0976f81e Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 28 Sep 2026 19:01:44 +0800 Subject: [PATCH] remove independent egress pool configuration and authorization --- AGENTS.md | 4 +- cmd/sip-go-agent/main_test.go | 2 +- .../v0.3/ai-authorization-v0.2.schema.json | 109 ++++++ .../local/v0.3/config-read-v0.3.schema.json | 173 +++++++++ .../v0.3/examples/ai-authorization-v0.2.json | 15 + .../v0.3/examples/config-read-sip-v0.3.json | 36 ++ ...invalid-ai-authorization-revoked-v0.2.json | 12 + .../static-cell-artifact-real-v2.json | 3 - .../examples/static-cell-artifact-v0.2.json | 44 +++ .../static-cell-artifact-v0.2.schema.json | 363 ++++++++++++++++++ deploys/config/static-cell-artifact-v2.json | 92 +++++ .../ai-authorization-v0.2.schema.json | 109 ++++++ .../config-read-fields-v0.3-proposal.md | 15 + docs/contracts/config-read-v0.3.schema.json | 173 +++++++++ .../ai-authorization-invalid-egress-v0.2.json | 18 + .../examples/ai-authorization-v0.2.json | 15 + .../config-read-sip-invalid-egress-v0.3.json | 37 ++ .../examples/config-read-sip-v0.3.json | 36 ++ ...tic-cell-artifact-invalid-egress-v0.2.json | 45 +++ .../examples/static-cell-artifact-v0.2.json | 44 +++ .../local-contract-manifest-v0.2.json | 2 +- .../local-contract-manifest-v0.5.json | 2 +- .../local-contract-manifest-v0.6.json | 16 + .../static-cell-artifact-v0.2.schema.json | 363 ++++++++++++++++++ docs/thirds/v0.2.md | 9 +- internal/ai/authorization.go | 41 +- internal/ai/authorization_test.go | 36 +- internal/configread/client_test.go | 17 +- internal/contract/contract.go | 6 +- internal/contract/schema.go | 2 +- internal/contract/static_artifact.go | 31 +- internal/contract/static_artifact_test.go | 64 +-- internal/dispatcher/dial_policy_test.go | 2 +- internal/dispatcher/local_flow_test.go | 19 +- .../dispatcher/local_v01_integration_test.go | 2 +- internal/dispatcher/local_v01_test.go | 6 +- internal/rpc/ai_authorization_test.go | 6 +- internal/rpc/server.go | 9 +- internal/rpc/static_artifact_test.go | 17 +- internal/store/ai_authorization.go | 7 +- internal/store/ai_authorization_test.go | 11 +- internal/store/ai_revocation_test.go | 2 +- scripts/validate-local-contracts.py | 51 ++- 43 files changed, 1899 insertions(+), 167 deletions(-) create mode 100644 contracts/local/v0.3/ai-authorization-v0.2.schema.json create mode 100644 contracts/local/v0.3/config-read-v0.3.schema.json create mode 100644 contracts/local/v0.3/examples/ai-authorization-v0.2.json create mode 100644 contracts/local/v0.3/examples/config-read-sip-v0.3.json create mode 100644 contracts/local/v0.3/examples/invalid-ai-authorization-revoked-v0.2.json rename deploys/config/static-cell-artifact-v1.json => contracts/local/v0.3/examples/static-cell-artifact-real-v2.json (95%) create mode 100644 contracts/local/v0.3/examples/static-cell-artifact-v0.2.json create mode 100644 contracts/local/v0.3/static-cell-artifact-v0.2.schema.json create mode 100644 deploys/config/static-cell-artifact-v2.json create mode 100644 docs/contracts/ai-authorization-v0.2.schema.json create mode 100644 docs/contracts/config-read-fields-v0.3-proposal.md create mode 100644 docs/contracts/config-read-v0.3.schema.json create mode 100644 docs/contracts/examples/ai-authorization-invalid-egress-v0.2.json create mode 100644 docs/contracts/examples/ai-authorization-v0.2.json create mode 100644 docs/contracts/examples/config-read-sip-invalid-egress-v0.3.json create mode 100644 docs/contracts/examples/config-read-sip-v0.3.json create mode 100644 docs/contracts/examples/static-cell-artifact-invalid-egress-v0.2.json create mode 100644 docs/contracts/examples/static-cell-artifact-v0.2.json create mode 100644 docs/contracts/local-contract-manifest-v0.6.json create mode 100644 docs/contracts/static-cell-artifact-v0.2.schema.json diff --git a/AGENTS.md b/AGENTS.md index b19b73e..7dbfce2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,7 +156,7 @@ - 已有model/prompt/voice/speed/ASR输入与识别/temperature/max_tokens/timeout及对话控制必须实际传入SDK或控制器;热词/VAD/top_p/音量/阶段时限等所需扩展先在上游补GAP-09,再生成校验。严格additionalProperties不放宽,不借metadata/raw_request透传。 - **现行 AI 合同**:SaaS新版本供新任务引用,无需改代码/重启D/A;在途/原排队任务固定快照,同版本异内容拒绝。缓存按租户+版本隔离,断SaaS无有效授权缓存拒新准入;显式0/false与未提供保真,并发通话不得共享可变SDK参数。 - **项目内配置与执行(已发布外部 v1 不变)**:项目内 v0.4 任务发现、控制及外呼入站依据 `docs/thirds/v0.4.md`;其它必要边界见 `docs/thirds/第三方对接事件与请求消费顺序_v0.1.md`。拟定 SaaS 只读路径为 `/internal/v1/dispatcher/sip`、`/internal/v1/dispatcher/task/:task_id`、`/internal/v1/dispatcher/tasks` 和 `/internal/v1/dispatcher/tenant/:tenant_id/quota`;均为项目内字段/路径,实际 SaaS 兼容性未验证。D 用 `X-DISPATCHER-id`/`X-DISPATCHER-SECRET-KEY` 按需读取,不在源码、配置样例或日志暴露凭据。SIP 启动先取获批全量并核验 Agent/Asterisk 加载后才准入;任务按租户+任务缓存约 60 秒,到期重新读取完整 200,无 ETag、过期缓存或 MQ 配置回退;已接纳执行固定原快照,暂停/停止控制不等待缓存。项目内 v0.4 `call.execute` 入站只有 `task_id/callee`,调用路由、主叫、AI 和时限绑定已授权任务快照;历史 MQ 命令不因年龄过期,但拨号前门禁不放宽。任务终止仅允许**显式、条件式**清理任务配置副本,不删执行恢复、幂等或 outbox。SIP 改动须关准入、排空及核验实际加载;外部 opt-out 现行 MQ 即时语义不可擅自改称已切换为最终 `call.result`。F01/F07 本地 Schema、正反例、来源/hash 与 Mock C 已通过,外部签收/连通仍未验证。 -- 凭据/供应商端点来自受控引用且有授权/出口校验,不能因可调参数绕过安全硬限额或启用不安全重试。OpenAI默认自动重试显式关闭;日志只留脱敏版本/摘要/有效参数,不打印prompt/变量/密钥。 +- 凭据/供应商端点来自受控引用且有授权校验;不再定义独立出口池标识或出口池授权名单,Dispatcher 仍校验任务允许线路、全局号码白名单、时段和额度,不能因可调参数绕过安全硬限额或启用不安全重试。OpenAI默认自动重试显式关闭;日志只留脱敏版本/摘要/有效参数,不打印prompt/变量/密钥。 - 静态发布只约束SIP/节点制品,不将AI配置硬编码;GAP-08/09及SDK参数PoC为P1门禁,验证入口见验收§5.1(现有E/L项子场景,不新增虚假通过数)。 ## 契约与可靠性 @@ -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配置字段项目内 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 实际加载仍须单独验证。当前唯一权威运行契约不因草案变化。 +- 新HTTP配置字段项目内 SIP 新版见 `docs/contracts/config-read-fields-v0.3-proposal.md`、`config-read-v0.3.schema.json`/mock示例;AI 授权和静态 Cell 制品的项目内新版见同一提案;旧 v1/v0.2 保留历史,不作为当前运行契约;任务/额度仍参照 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映射另行核验,不猜字段。 diff --git a/cmd/sip-go-agent/main_test.go b/cmd/sip-go-agent/main_test.go index 2021961..34eb968 100644 --- a/cmd/sip-go-agent/main_test.go +++ b/cmd/sip-go-agent/main_test.go @@ -55,7 +55,7 @@ func TestValidateCallAISnapshotAllowsASROnly(t *testing.T) { } func TestBuildCallEndpointUsesArtifactRoute(t *testing.T) { - raw, err := contracts.Read("examples/static-cell-artifact-real-v1.json") + raw, err := contracts.Files.ReadFile("local/v0.3/examples/static-cell-artifact-real-v2.json") if err != nil { t.Fatal(err) } diff --git a/contracts/local/v0.3/ai-authorization-v0.2.schema.json b/contracts/local/v0.3/ai-authorization-v0.2.schema.json new file mode 100644 index 0000000..566040d --- /dev/null +++ b/contracts/local/v0.3/ai-authorization-v0.2.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/ai-authorization-v0.2.schema.json", + "title": "Dispatcher to Agent immutable AI authorization", + "type": "object", + "additionalProperties": false, + "required": [ + "authorization_id", + "tenant_id", + "tenant_key", + "agent_version_id", + "config_sha256", + "mode", + "issued_at", + "expires_at", + "source", + "revoked" + ], + "properties": { + "authorization_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tenant_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tenant_key": { + "type": "string", + "minLength": 1, + "maxLength": 224 + }, + "agent_version_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "config_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "mode": { + "enum": [ + "full_ai", + "asr_only" + ] + }, + "issued_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "source": { + "enum": [ + "saas", + "mock-saas" + ] + }, + "credential_refs": { + "type": "object", + "additionalProperties": false, + "properties": { + "asr": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "llm": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tts": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "revoked": { + "type": "boolean" + }, + "revocation_reason": { + "type": "string", + "maxLength": 256 + } + }, + "allOf": [ + { + "if": { + "properties": { + "revoked": { + "const": true + } + } + }, + "then": { + "required": [ + "revocation_reason" + ] + } + } + ] +} diff --git a/contracts/local/v0.3/config-read-v0.3.schema.json b/contracts/local/v0.3/config-read-v0.3.schema.json new file mode 100644 index 0000000..b1a8559 --- /dev/null +++ b/contracts/local/v0.3/config-read-v0.3.schema.json @@ -0,0 +1,173 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/config-read-v0.3.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.3"}, + "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", "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}, + "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/contracts/local/v0.3/examples/ai-authorization-v0.2.json b/contracts/local/v0.3/examples/ai-authorization-v0.2.json new file mode 100644 index 0000000..7298b2a --- /dev/null +++ b/contracts/local/v0.3/examples/ai-authorization-v0.2.json @@ -0,0 +1,15 @@ +{ + "authorization_id": "auth-1", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "agent_version_id": "agent_asr_v1", + "config_sha256": "51a1f367066aaaaa7c5f5ce50b229eb8644d27ada57f8b5575c254dd4c9930d7", + "mode": "asr_only", + "issued_at": "2026-09-18T00:00:00Z", + "expires_at": "2026-09-18T00:01:00Z", + "source": "mock-saas", + "credential_refs": { + "asr": "mock-asr-credential" + }, + "revoked": false +} diff --git a/contracts/local/v0.3/examples/config-read-sip-v0.3.json b/contracts/local/v0.3/examples/config-read-sip-v0.3.json new file mode 100644 index 0000000..93060b9 --- /dev/null +++ b/contracts/local/v0.3/examples/config-read-sip-v0.3.json @@ -0,0 +1,36 @@ +{ + "schema_version": "config-read.v0.3", + "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", + "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/contracts/local/v0.3/examples/invalid-ai-authorization-revoked-v0.2.json b/contracts/local/v0.3/examples/invalid-ai-authorization-revoked-v0.2.json new file mode 100644 index 0000000..48a591b --- /dev/null +++ b/contracts/local/v0.3/examples/invalid-ai-authorization-revoked-v0.2.json @@ -0,0 +1,12 @@ +{ + "authorization_id": "auth-invalid", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "agent_version_id": "agent_asr_v1", + "config_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "mode": "asr_only", + "issued_at": "2026-09-18T00:00:00Z", + "expires_at": "2026-09-18T00:01:00Z", + "source": "mock-saas", + "revoked": true +} diff --git a/deploys/config/static-cell-artifact-v1.json b/contracts/local/v0.3/examples/static-cell-artifact-real-v2.json similarity index 95% rename from deploys/config/static-cell-artifact-v1.json rename to contracts/local/v0.3/examples/static-cell-artifact-real-v2.json index 6e2c7bb..42cb364 100644 --- a/deploys/config/static-cell-artifact-v1.json +++ b/contracts/local/v0.3/examples/static-cell-artifact-real-v2.json @@ -11,7 +11,6 @@ { "trunk_id": "provider-primary", "provider_id": "provider-primary", - "egress_pool_id": "egress-single", "codec": "PCMA", "caller_profile_ids": [ "caller-primary" @@ -25,7 +24,6 @@ { "trunk_id": "provider-second", "provider_id": "provider-second", - "egress_pool_id": "egress-single", "codec": "PCMA", "caller_profile_ids": [ "caller-second" @@ -39,7 +37,6 @@ { "trunk_id": "provider-third", "provider_id": "provider-third", - "egress_pool_id": "egress-single", "codec": "PCMA", "caller_profile_ids": [ "caller-third" diff --git a/contracts/local/v0.3/examples/static-cell-artifact-v0.2.json b/contracts/local/v0.3/examples/static-cell-artifact-v0.2.json new file mode 100644 index 0000000..df62cb6 --- /dev/null +++ b/contracts/local/v0.3/examples/static-cell-artifact-v0.2.json @@ -0,0 +1,44 @@ +{ + "artifact_id": "artifact-cell-a-1", + "source_release": "management-snapshot-1", + "source_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "approval_reference": "mock-approval-1", + "cell_id": "cell-a", + "revision": 1, + "config_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "mode": "mock", + "trunks": [ + { + "trunk_id": "trunk-mock", + "provider_id": "provider-mock", + "codec": "PCMA", + "caller_profile_ids": [ + "caller_profile_test" + ], + "dial_prefix": "7089", + "enabled": true, + "sip_endpoint_ref": "mock-sip-endpoint", + "credential_ref": null, + "media_profile_id": "slin16-16k-pt118" + } + ], + "load_evidence": null, + "allowed_targets": [ + "15003164745", + "15830461047" + ], + "media_profiles": { + "pcma-8k-pt8": { + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + }, + "slin16-16k-pt118": { + "format": "slin16", + "sample_rate_hz": 16000, + "channels": 1, + "payload_type": 118 + } + } +} diff --git a/contracts/local/v0.3/static-cell-artifact-v0.2.schema.json b/contracts/local/v0.3/static-cell-artifact-v0.2.schema.json new file mode 100644 index 0000000..a2098e0 --- /dev/null +++ b/contracts/local/v0.3/static-cell-artifact-v0.2.schema.json @@ -0,0 +1,363 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/static-cell-artifact-v0.2.schema.json", + "title": "Project-owned v1 static Cell/SIP hand-off artifact", + "type": "object", + "additionalProperties": false, + "required": [ + "artifact_id", + "source_release", + "source_digest", + "approval_reference", + "cell_id", + "revision", + "config_sha256", + "mode", + "allowed_targets", + "trunks" + ], + "properties": { + "artifact_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "source_release": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "source_digest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "approval_reference": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "cell_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "config_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "mode": { + "enum": [ + "mock", + "mixed", + "real" + ] + }, + "allowed_targets": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9]{11,15}$" + } + }, + "trunks": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "trunk_id", + "provider_id", + "codec", + "caller_profile_ids", + "dial_prefix", + "enabled", + "media_profile_id" + ], + "properties": { + "trunk_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "provider_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "codec": { + "const": "PCMA" + }, + "caller_profile_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "dial_prefix": { + "type": "string", + "maxLength": 32 + }, + "enabled": { + "type": "boolean" + }, + "sip_endpoint_ref": { + "type": "string", + "maxLength": 128 + }, + "credential_ref": { + "type": [ + "string", + "null" + ], + "maxLength": 128 + }, + "media_profile_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + } + }, + "ari": { + "type": "object", + "additionalProperties": false, + "required": [ + "base_url", + "websocket_url", + "application", + "credential_ref" + ], + "properties": { + "base_url": { + "type": "string", + "format": "uri", + "pattern": "^https?://" + }, + "websocket_url": { + "type": "string", + "format": "uri", + "pattern": "^wss?://" + }, + "application": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + }, + "credential_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "media_profiles": { + "type": "object", + "minProperties": 1, + "maxProperties": 16, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "sample_rate_hz", + "channels", + "payload_type" + ], + "properties": { + "format": { + "enum": [ + "slin16", + "alaw" + ] + }, + "sample_rate_hz": { + "enum": [ + 8000, + 16000 + ] + }, + "channels": { + "const": 1 + }, + "payload_type": { + "type": "integer", + "minimum": 0, + "maximum": 127 + } + }, + "allOf": [ + { + "if": { + "properties": { + "format": { + "const": "alaw" + } + } + }, + "then": { + "properties": { + "sample_rate_hz": { + "const": 8000 + }, + "payload_type": { + "const": 8 + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "slin16" + } + } + }, + "then": { + "properties": { + "sample_rate_hz": { + "const": 16000 + }, + "payload_type": { + "type": "integer", + "minimum": 96, + "maximum": 127 + } + } + } + } + ] + } + }, + "media": { + "type": "object", + "additionalProperties": false, + "required": [ + "bind_address", + "port", + "format", + "sample_rate_hz", + "channels", + "payload_type" + ], + "properties": { + "bind_address": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "port": { + "type": "integer", + "minimum": 1024, + "maximum": 65535 + }, + "format": { + "enum": [ + "slin16", + "alaw" + ] + }, + "sample_rate_hz": { + "enum": [ + 8000, + 16000 + ] + }, + "channels": { + "const": 1 + }, + "payload_type": { + "type": "integer", + "minimum": 0, + "maximum": 127 + } + } + }, + "recording": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "format", + "directory", + "max_bytes" + ], + "properties": { + "enabled": { + "const": true + }, + "format": { + "const": "wav" + }, + "directory": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "max_bytes": { + "type": "integer", + "minimum": 16000, + "maximum": 1073741824 + } + } + }, + "load_evidence": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "properties": { + "asterisk_config_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "loaded_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "enum": [ + "not-yet-loaded", + "loaded" + ] + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { + "enum": [ + "mixed", + "real" + ] + } + } + }, + "then": { + "required": [ + "ari", + "media", + "media_profiles", + "recording" + ] + } + } + ] +} diff --git a/deploys/config/static-cell-artifact-v2.json b/deploys/config/static-cell-artifact-v2.json new file mode 100644 index 0000000..42cb364 --- /dev/null +++ b/deploys/config/static-cell-artifact-v2.json @@ -0,0 +1,92 @@ +{ + "artifact_id": "cell-single-real-v1", + "source_release": "asterisk-22.10.1-native-v1", + "source_digest": "68006a1a8efed288be4ca4a2ae3cb9554a31d733eac08eaacf4c646c95faf74d", + "approval_reference": "project-auto-approved:2026-09-19:single-node-v1", + "cell_id": "cell-single", + "revision": 1, + "config_sha256": "89d2686d0d1ca60159c3c6bd725dc9e6f511cbdb56bf6ce7b65ca7d4dc3f2d60", + "mode": "real", + "trunks": [ + { + "trunk_id": "provider-primary", + "provider_id": "provider-primary", + "codec": "PCMA", + "caller_profile_ids": [ + "caller-primary" + ], + "dial_prefix": "7089", + "enabled": true, + "sip_endpoint_ref": "provider-primary", + "credential_ref": "sip-provider-primary", + "media_profile_id": "pcma-8k-pt8" + }, + { + "trunk_id": "provider-second", + "provider_id": "provider-second", + "codec": "PCMA", + "caller_profile_ids": [ + "caller-second" + ], + "dial_prefix": "", + "enabled": true, + "sip_endpoint_ref": "provider-second", + "credential_ref": "sip-provider-second", + "media_profile_id": "pcma-8k-pt8" + }, + { + "trunk_id": "provider-third", + "provider_id": "provider-third", + "codec": "PCMA", + "caller_profile_ids": [ + "caller-third" + ], + "dial_prefix": "mka755", + "enabled": true, + "sip_endpoint_ref": "provider-third", + "credential_ref": "sip-provider-third", + "media_profile_id": "pcma-8k-pt8" + } + ], + "ari": { + "base_url": "https://127.0.0.1:8088/ari", + "websocket_url": "wss://127.0.0.1:8088/ari/events", + "application": "agent-call", + "credential_ref": "ari-single" + }, + "media": { + "bind_address": "127.0.0.1", + "port": 12000, + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + }, + "recording": { + "enabled": true, + "format": "wav", + "directory": "/var/lib/sip-go-agent/recordings", + "max_bytes": 67108864 + }, + "load_evidence": { + "status": "not-yet-loaded" + }, + "allowed_targets": [ + "15003164745", + "15830461047" + ], + "media_profiles": { + "pcma-8k-pt8": { + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + }, + "slin16-16k-pt118": { + "format": "slin16", + "sample_rate_hz": 16000, + "channels": 1, + "payload_type": 118 + } + } +} diff --git a/docs/contracts/ai-authorization-v0.2.schema.json b/docs/contracts/ai-authorization-v0.2.schema.json new file mode 100644 index 0000000..566040d --- /dev/null +++ b/docs/contracts/ai-authorization-v0.2.schema.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/ai-authorization-v0.2.schema.json", + "title": "Dispatcher to Agent immutable AI authorization", + "type": "object", + "additionalProperties": false, + "required": [ + "authorization_id", + "tenant_id", + "tenant_key", + "agent_version_id", + "config_sha256", + "mode", + "issued_at", + "expires_at", + "source", + "revoked" + ], + "properties": { + "authorization_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tenant_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tenant_key": { + "type": "string", + "minLength": 1, + "maxLength": 224 + }, + "agent_version_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "config_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "mode": { + "enum": [ + "full_ai", + "asr_only" + ] + }, + "issued_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "source": { + "enum": [ + "saas", + "mock-saas" + ] + }, + "credential_refs": { + "type": "object", + "additionalProperties": false, + "properties": { + "asr": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "llm": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tts": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "revoked": { + "type": "boolean" + }, + "revocation_reason": { + "type": "string", + "maxLength": 256 + } + }, + "allOf": [ + { + "if": { + "properties": { + "revoked": { + "const": true + } + } + }, + "then": { + "required": [ + "revocation_reason" + ] + } + } + ] +} diff --git a/docs/contracts/config-read-fields-v0.3-proposal.md b/docs/contracts/config-read-fields-v0.3-proposal.md new file mode 100644 index 0000000..96aa384 --- /dev/null +++ b/docs/contracts/config-read-fields-v0.3-proposal.md @@ -0,0 +1,15 @@ +# 项目内出口池字段收敛(v0.3 提案) + +此提案是项目内 Mock 契约,不代表 SaaS/management/AI 供应商已发布或签收。旧导入的 `contracts/upstream/v1/` 和 SIP v0.2 包保持原样,仅作历史输入;真实外部切换须另行核验新版权威来源。 + +## SIP 只读配置 + +`GET /internal/v1/dispatcher/sip` 的 200 使用 `config-read.v0.3`,依据 [Schema](config-read-v0.3.schema.json) 和 [正例](examples/config-read-sip-v0.3.json)。根级仍为 `schema_version/resource/dispatcher_id/revision/approved_at/trunks`;线路保留 `trunk_id/provider_id/codec/dial_prefix/enabled`、连接参数、主叫、额度和时段。**不定义 `egress_pool_id`**;[旧字段反例](examples/config-read-sip-invalid-egress-v0.3.json) 须被拒绝。任务与租户额度接口保持 v0.1。完整 SIP 版本单调递增,同版本内容变更必须拒绝;Agent 实际加载版本核对不变。 + +## AI 授权与静态 Cell 制品 + +用户已确认没有独立出口池授权。项目内 [AI 授权 v0.2 Schema](ai-authorization-v0.2.schema.json) 不再定义 `allowed_egress_pool_ids`,只保留租户、版本、不可变配置摘要、期限、撤销、供应商/凭据引用等检查;[正例](examples/ai-authorization-v0.2.json)和[旧字段反例](examples/ai-authorization-invalid-egress-v0.2.json)须分别通过/拒绝。 + +项目内 [静态 Cell 制品 v0.2 Schema](static-cell-artifact-v0.2.schema.json) 不再定义 `trunks[].egress_pool_id`,保留 Cell 身份、部署版本、线路、codec、端点引用与实际加载核验;[正例](examples/static-cell-artifact-v0.2.json)和[旧字段反例](examples/static-cell-artifact-invalid-egress-v0.2.json)须分别通过/拒绝。部署配置不来自 SaaS。 + +Dispatcher 仍按**任务允许线路**、全局号码白名单、任务与线路时段、额度、授权期限执行准入;选线固定后不自动换线或重拨。删除独立出口池条件不等于放宽上述限制。外部 v1 的 AI 授权/静态制品/分散事件 Schema 不原地修改;这些旧字段不得作为新版运行入口的依据。没有真实 SaaS/management/Agent-Asterisk 联调证据,不能宣称已对外切换。 diff --git a/docs/contracts/config-read-v0.3.schema.json b/docs/contracts/config-read-v0.3.schema.json new file mode 100644 index 0000000..b1a8559 --- /dev/null +++ b/docs/contracts/config-read-v0.3.schema.json @@ -0,0 +1,173 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/config-read-v0.3.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.3"}, + "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", "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}, + "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/ai-authorization-invalid-egress-v0.2.json b/docs/contracts/examples/ai-authorization-invalid-egress-v0.2.json new file mode 100644 index 0000000..4728fd6 --- /dev/null +++ b/docs/contracts/examples/ai-authorization-invalid-egress-v0.2.json @@ -0,0 +1,18 @@ +{ + "authorization_id": "auth-1", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "agent_version_id": "agent_asr_v1", + "config_sha256": "51a1f367066aaaaa7c5f5ce50b229eb8644d27ada57f8b5575c254dd4c9930d7", + "mode": "asr_only", + "issued_at": "2026-09-18T00:00:00Z", + "expires_at": "2026-09-18T00:01:00Z", + "source": "mock-saas", + "credential_refs": { + "asr": "mock-asr-credential" + }, + "allowed_egress_pool_ids": [ + "egress-mock" + ], + "revoked": false +} diff --git a/docs/contracts/examples/ai-authorization-v0.2.json b/docs/contracts/examples/ai-authorization-v0.2.json new file mode 100644 index 0000000..7298b2a --- /dev/null +++ b/docs/contracts/examples/ai-authorization-v0.2.json @@ -0,0 +1,15 @@ +{ + "authorization_id": "auth-1", + "tenant_id": "tenant-1", + "tenant_key": "tenant-demo-key", + "agent_version_id": "agent_asr_v1", + "config_sha256": "51a1f367066aaaaa7c5f5ce50b229eb8644d27ada57f8b5575c254dd4c9930d7", + "mode": "asr_only", + "issued_at": "2026-09-18T00:00:00Z", + "expires_at": "2026-09-18T00:01:00Z", + "source": "mock-saas", + "credential_refs": { + "asr": "mock-asr-credential" + }, + "revoked": false +} diff --git a/docs/contracts/examples/config-read-sip-invalid-egress-v0.3.json b/docs/contracts/examples/config-read-sip-invalid-egress-v0.3.json new file mode 100644 index 0000000..dceccfe --- /dev/null +++ b/docs/contracts/examples/config-read-sip-invalid-egress-v0.3.json @@ -0,0 +1,37 @@ +{ + "schema_version": "config-read.v0.3", + "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/examples/config-read-sip-v0.3.json b/docs/contracts/examples/config-read-sip-v0.3.json new file mode 100644 index 0000000..93060b9 --- /dev/null +++ b/docs/contracts/examples/config-read-sip-v0.3.json @@ -0,0 +1,36 @@ +{ + "schema_version": "config-read.v0.3", + "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", + "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/static-cell-artifact-invalid-egress-v0.2.json b/docs/contracts/examples/static-cell-artifact-invalid-egress-v0.2.json new file mode 100644 index 0000000..bc4c0b2 --- /dev/null +++ b/docs/contracts/examples/static-cell-artifact-invalid-egress-v0.2.json @@ -0,0 +1,45 @@ +{ + "artifact_id": "artifact-cell-a-1", + "source_release": "management-snapshot-1", + "source_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "approval_reference": "mock-approval-1", + "cell_id": "cell-a", + "revision": 1, + "config_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "mode": "mock", + "trunks": [ + { + "trunk_id": "trunk-mock", + "provider_id": "provider-mock", + "egress_pool_id": "egress-mock", + "codec": "PCMA", + "caller_profile_ids": [ + "caller_profile_test" + ], + "dial_prefix": "7089", + "enabled": true, + "sip_endpoint_ref": "mock-sip-endpoint", + "credential_ref": null, + "media_profile_id": "slin16-16k-pt118" + } + ], + "load_evidence": null, + "allowed_targets": [ + "15003164745", + "15830461047" + ], + "media_profiles": { + "pcma-8k-pt8": { + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + }, + "slin16-16k-pt118": { + "format": "slin16", + "sample_rate_hz": 16000, + "channels": 1, + "payload_type": 118 + } + } +} diff --git a/docs/contracts/examples/static-cell-artifact-v0.2.json b/docs/contracts/examples/static-cell-artifact-v0.2.json new file mode 100644 index 0000000..df62cb6 --- /dev/null +++ b/docs/contracts/examples/static-cell-artifact-v0.2.json @@ -0,0 +1,44 @@ +{ + "artifact_id": "artifact-cell-a-1", + "source_release": "management-snapshot-1", + "source_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "approval_reference": "mock-approval-1", + "cell_id": "cell-a", + "revision": 1, + "config_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "mode": "mock", + "trunks": [ + { + "trunk_id": "trunk-mock", + "provider_id": "provider-mock", + "codec": "PCMA", + "caller_profile_ids": [ + "caller_profile_test" + ], + "dial_prefix": "7089", + "enabled": true, + "sip_endpoint_ref": "mock-sip-endpoint", + "credential_ref": null, + "media_profile_id": "slin16-16k-pt118" + } + ], + "load_evidence": null, + "allowed_targets": [ + "15003164745", + "15830461047" + ], + "media_profiles": { + "pcma-8k-pt8": { + "format": "alaw", + "sample_rate_hz": 8000, + "channels": 1, + "payload_type": 8 + }, + "slin16-16k-pt118": { + "format": "slin16", + "sample_rate_hz": 16000, + "channels": 1, + "payload_type": 118 + } + } +} diff --git a/docs/contracts/local-contract-manifest-v0.2.json b/docs/contracts/local-contract-manifest-v0.2.json index 5c2a3c6..af8d841 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": "bda3af43816b03e0b8ff164998e083282cfe95b1040deee7e62d5cf7c179912b"}, + "source": {"path": "docs/thirds/v0.2.md", "sha256": "446a8726a8ce86f3a5910e9bc2f004a5142934ed7e5bc8146c075f682337d16e"}, "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 index 328d794..0a09fba 100644 --- a/docs/contracts/local-contract-manifest-v0.5.json +++ b/docs/contracts/local-contract-manifest-v0.5.json @@ -3,7 +3,7 @@ "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/thirds/v0.2.md", "sha256": "446a8726a8ce86f3a5910e9bc2f004a5142934ed7e5bc8146c075f682337d16e"}, {"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/contracts/local-contract-manifest-v0.6.json b/docs/contracts/local-contract-manifest-v0.6.json new file mode 100644 index 0000000..e6e35ce --- /dev/null +++ b/docs/contracts/local-contract-manifest-v0.6.json @@ -0,0 +1,16 @@ +{ + "manifest_version": "local-contract-manifest.v0.6", + "hash_algorithm": "SHA-256", + "source": {"path": "docs/contracts/config-read-fields-v0.3-proposal.md", "sha256": "12539253327218dfccf585d4bbf473165542c581385b901488bfe7160354e2ed"}, + "artifacts": [ + {"path": "docs/contracts/config-read-v0.3.schema.json", "sha256": "df7c0c4e77d102a35793b8aa5a17326d35a2bd7ced04be6ef50bb9d52df64036"}, + {"path": "docs/contracts/examples/config-read-sip-v0.3.json", "sha256": "b45d56a4d550f05fba7d222b51e85528b7d8ad465e1dca5f86375676fa0cc8b9"}, + {"path": "docs/contracts/examples/config-read-sip-invalid-egress-v0.3.json", "sha256": "418802ad781454b1f72ae032bfb0310e0b67927d1a407627564e8fa4fa19f94d"}, + {"path": "docs/contracts/ai-authorization-v0.2.schema.json", "sha256": "8e5da3c374857518f9589e62e5bfd08a58328820d29204acf441ccd0eaecbac6"}, + {"path": "docs/contracts/examples/ai-authorization-v0.2.json", "sha256": "85528d6279c0057269b34a9b7dfa748c27f0ef85379a2dd934df68d62cc8fc28"}, + {"path": "docs/contracts/examples/ai-authorization-invalid-egress-v0.2.json", "sha256": "2aacf5b3a8afbc5c457ad008323559f1d51ce31c85741631481b46c46118168d"}, + {"path": "docs/contracts/static-cell-artifact-v0.2.schema.json", "sha256": "72029d19309b719fa88271ea0f3fa0ac86240cad8288852d2ef9a1a3071769eb"}, + {"path": "docs/contracts/examples/static-cell-artifact-v0.2.json", "sha256": "abe9585a45039c4af55c695e542a38e659be6f7247274ef51fb8e8f255bfb87e"}, + {"path": "docs/contracts/examples/static-cell-artifact-invalid-egress-v0.2.json", "sha256": "129821e4de654ef12d3e7f155e9301ecddf798f9ca3399316a20a55273fbc61f"} + ] +} diff --git a/docs/contracts/static-cell-artifact-v0.2.schema.json b/docs/contracts/static-cell-artifact-v0.2.schema.json new file mode 100644 index 0000000..a2098e0 --- /dev/null +++ b/docs/contracts/static-cell-artifact-v0.2.schema.json @@ -0,0 +1,363 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://go-sip.local/contracts/proposals/static-cell-artifact-v0.2.schema.json", + "title": "Project-owned v1 static Cell/SIP hand-off artifact", + "type": "object", + "additionalProperties": false, + "required": [ + "artifact_id", + "source_release", + "source_digest", + "approval_reference", + "cell_id", + "revision", + "config_sha256", + "mode", + "allowed_targets", + "trunks" + ], + "properties": { + "artifact_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "source_release": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "source_digest": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "approval_reference": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "cell_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "revision": { + "type": "integer", + "minimum": 1 + }, + "config_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "mode": { + "enum": [ + "mock", + "mixed", + "real" + ] + }, + "allowed_targets": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9]{11,15}$" + } + }, + "trunks": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "trunk_id", + "provider_id", + "codec", + "caller_profile_ids", + "dial_prefix", + "enabled", + "media_profile_id" + ], + "properties": { + "trunk_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "provider_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "codec": { + "const": "PCMA" + }, + "caller_profile_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + "dial_prefix": { + "type": "string", + "maxLength": 32 + }, + "enabled": { + "type": "boolean" + }, + "sip_endpoint_ref": { + "type": "string", + "maxLength": 128 + }, + "credential_ref": { + "type": [ + "string", + "null" + ], + "maxLength": 128 + }, + "media_profile_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + } + }, + "ari": { + "type": "object", + "additionalProperties": false, + "required": [ + "base_url", + "websocket_url", + "application", + "credential_ref" + ], + "properties": { + "base_url": { + "type": "string", + "format": "uri", + "pattern": "^https?://" + }, + "websocket_url": { + "type": "string", + "format": "uri", + "pattern": "^wss?://" + }, + "application": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" + }, + "credential_ref": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "media_profiles": { + "type": "object", + "minProperties": 1, + "maxProperties": 16, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "sample_rate_hz", + "channels", + "payload_type" + ], + "properties": { + "format": { + "enum": [ + "slin16", + "alaw" + ] + }, + "sample_rate_hz": { + "enum": [ + 8000, + 16000 + ] + }, + "channels": { + "const": 1 + }, + "payload_type": { + "type": "integer", + "minimum": 0, + "maximum": 127 + } + }, + "allOf": [ + { + "if": { + "properties": { + "format": { + "const": "alaw" + } + } + }, + "then": { + "properties": { + "sample_rate_hz": { + "const": 8000 + }, + "payload_type": { + "const": 8 + } + } + } + }, + { + "if": { + "properties": { + "format": { + "const": "slin16" + } + } + }, + "then": { + "properties": { + "sample_rate_hz": { + "const": 16000 + }, + "payload_type": { + "type": "integer", + "minimum": 96, + "maximum": 127 + } + } + } + } + ] + } + }, + "media": { + "type": "object", + "additionalProperties": false, + "required": [ + "bind_address", + "port", + "format", + "sample_rate_hz", + "channels", + "payload_type" + ], + "properties": { + "bind_address": { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "port": { + "type": "integer", + "minimum": 1024, + "maximum": 65535 + }, + "format": { + "enum": [ + "slin16", + "alaw" + ] + }, + "sample_rate_hz": { + "enum": [ + 8000, + 16000 + ] + }, + "channels": { + "const": 1 + }, + "payload_type": { + "type": "integer", + "minimum": 0, + "maximum": 127 + } + } + }, + "recording": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "format", + "directory", + "max_bytes" + ], + "properties": { + "enabled": { + "const": true + }, + "format": { + "const": "wav" + }, + "directory": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "max_bytes": { + "type": "integer", + "minimum": 16000, + "maximum": 1073741824 + } + } + }, + "load_evidence": { + "type": [ + "object", + "null" + ], + "additionalProperties": false, + "properties": { + "asterisk_config_sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "loaded_at": { + "type": "string", + "format": "date-time" + }, + "status": { + "enum": [ + "not-yet-loaded", + "loaded" + ] + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { + "enum": [ + "mixed", + "real" + ] + } + } + }, + "then": { + "required": [ + "ari", + "media", + "media_profiles", + "recording" + ] + } + } + ] +} diff --git a/docs/thirds/v0.2.md b/docs/thirds/v0.2.md index 0597654..60cfd24 100644 --- a/docs/thirds/v0.2.md +++ b/docs/thirds/v0.2.md @@ -1,4 +1,4 @@ -- 本文是新版项目内字段与状态语义的说明;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 契约。 +- 本文是新版项目内字段与状态语义的说明;v0.1 文档及证据仅留历史,不作为新版运行契约。四条拟定 GET 的响应字段、路径和外部兼容性尚待真实 SaaS 核对。SIP 新版机器校验为[配置读取 v0.3 Schema](../contracts/config-read-v0.3.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,不按消息身份去重,重复动作只保持状态幂等。 @@ -74,7 +74,7 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> ```json { - "schema_version": "config-read.v0.2", + "schema_version": "config-read.v0.3", "resource": "sip_config", "dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", "revision": 1, @@ -83,7 +83,6 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> { "trunk_id": "trunk-mock", "provider_id": "provider-mock", - "egress_pool_id": "egress-mock", "codec": "PCMA", "dial_prefix": "", "enabled": true, @@ -144,9 +143,9 @@ X-DISPATCHER-SECRET-KEY: <受控注入,不展示实际密钥> **字段说明/消费动作:** > Cell:一个外呼应用Asterisk实例(当前阶段不扩展复杂分布式,写死单实例数据,仅填充Trunk 数据列表,后期根据需求调整分布式架构); > Trunk: 一条外呼线路; -- `schema_version/resource`:草案版本 `config-read.v0.2`、资源 `sip_config`;`dispatcher_id`:只能与发起请求的 D 相同。 +- `schema_version/resource`:草案版本 `config-read.v0.3`、资源 `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 和主叫引用不能重复。 +- `trunks[]`:唯一的线路列表;`trunk_id/provider_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/ai/authorization.go b/internal/ai/authorization.go index 99a1de2..0a779bf 100644 --- a/internal/ai/authorization.go +++ b/internal/ai/authorization.go @@ -10,25 +10,24 @@ import ( ) type Authorization struct { - AuthorizationID string `json:"authorization_id"` - TenantID string `json:"tenant_id"` - TenantKey string `json:"tenant_key"` - AgentVersionID string `json:"agent_version_id"` - ConfigSHA256 string `json:"config_sha256"` - Mode Mode `json:"mode"` - IssuedAt string `json:"issued_at"` - ExpiresAt string `json:"expires_at"` - Source string `json:"source"` - CredentialRefs map[string]string `json:"credential_refs"` - AllowedEgressPoolIDs []string `json:"allowed_egress_pool_ids"` - Revoked bool `json:"revoked"` - RevocationReason string `json:"revocation_reason"` + AuthorizationID string `json:"authorization_id"` + TenantID string `json:"tenant_id"` + TenantKey string `json:"tenant_key"` + AgentVersionID string `json:"agent_version_id"` + ConfigSHA256 string `json:"config_sha256"` + Mode Mode `json:"mode"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` + Source string `json:"source"` + CredentialRefs map[string]string `json:"credential_refs"` + Revoked bool `json:"revoked"` + RevocationReason string `json:"revocation_reason"` } // DecodeBoundAuthorization validates identity and immutable configuration binding. // It deliberately preserves revoked/expired grants as facts, not permissions. func DecodeBoundAuthorization(raw []byte, snapshot Snapshot, tenantID, tenantKey string) (Authorization, error) { - if err := contract.ValidateSourceSchema("ai-authorization.schema.json", raw); err != nil { + if err := contract.ValidateLocalAIAuthorization(raw); err != nil { return Authorization{}, err } var authorization Authorization @@ -55,7 +54,7 @@ func DecodeBoundAuthorization(raw []byte, snapshot Snapshot, tenantID, tenantKey return authorization, nil } -func ValidateAuthorization(raw []byte, snapshot Snapshot, tenantID, tenantKey, egressPoolID string, now time.Time) (Authorization, error) { +func ValidateAuthorization(raw []byte, snapshot Snapshot, tenantID, tenantKey string, now time.Time) (Authorization, error) { authorization, err := DecodeBoundAuthorization(raw, snapshot, tenantID, tenantKey) if err != nil { return Authorization{}, err @@ -74,17 +73,5 @@ func ValidateAuthorization(raw []byte, snapshot Snapshot, tenantID, tenantKey, e if now.Before(issuedAt) || !now.Before(expiresAt) { return Authorization{}, errors.New("AI authorization is outside its validity window") } - if egressPoolID != "" { - allowed := false - for _, value := range authorization.AllowedEgressPoolIDs { - if value == egressPoolID { - allowed = true - break - } - } - if !allowed { - return Authorization{}, errors.New("AI authorization does not allow this egress pool") - } - } return authorization, nil } diff --git a/internal/ai/authorization_test.go b/internal/ai/authorization_test.go index 6147956..bd3a46a 100644 --- a/internal/ai/authorization_test.go +++ b/internal/ai/authorization_test.go @@ -1,13 +1,14 @@ package ai import ( + "bytes" "testing" "time" "git.ipao.vip/rogee/go-sip/contracts" ) -func TestValidateAuthorizationBindsSnapshotTenantAndEgress(t *testing.T) { +func TestValidateAuthorizationBindsSnapshotAndTenant(t *testing.T) { snapshotRaw, err := contracts.Read("examples/agent-version-asr-only.json") if err != nil { t.Fatal(err) @@ -16,20 +17,25 @@ func TestValidateAuthorizationBindsSnapshotTenantAndEgress(t *testing.T) { if err != nil { t.Fatal(err) } - authorizationRaw, err := contracts.Files.ReadFile("upstream/v1/examples/ai-authorization.json") + authorizationRaw, err := contracts.Files.ReadFile("local/v0.3/examples/ai-authorization-v0.2.json") if err != nil { t.Fatal(err) } - authorization, err := ValidateAuthorization(authorizationRaw, snapshot, "tenant-1", "tenant-demo-key", "egress-mock", time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC)) - if err != nil { + if _, err := ValidateAuthorization(authorizationRaw, snapshot, "tenant-1", "tenant-demo-key", time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC)); err != nil { t.Fatal(err) } - if authorization.AuthorizationID == "" || authorization.Mode != ModeASROnly { - t.Fatalf("unexpected authorization: %+v", authorization) + if _, err := ValidateAuthorization(authorizationRaw, snapshot, "other", "tenant-demo-key", time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC)); err == nil { + t.Fatal("expected tenant mismatch") + } + if _, err := ValidateAuthorization(authorizationRaw, snapshot, "tenant-1", "tenant-demo-key", time.Date(2026, 9, 18, 0, 2, 0, 0, time.UTC)); err == nil { + t.Fatal("expected expired authorization") + } + if _, err := ValidateAuthorization(bytes.Replace(authorizationRaw, []byte(`"revoked": false`), []byte(`"revoked": true`), 1), snapshot, "tenant-1", "tenant-demo-key", time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC)); err == nil { + t.Fatal("expected revocation rejection") } } -func TestValidateAuthorizationRejectsMismatchAndExpiry(t *testing.T) { +func TestAuthorizationRejectsRemovedEgressPoolField(t *testing.T) { snapshotRaw, err := contracts.Read("examples/agent-version-asr-only.json") if err != nil { t.Fatal(err) @@ -38,21 +44,11 @@ func TestValidateAuthorizationRejectsMismatchAndExpiry(t *testing.T) { if err != nil { t.Fatal(err) } - invalid, err := contracts.Read("examples/invalid-ai-authorization-revoked.json") + old, err := contracts.Files.ReadFile("upstream/v1/examples/ai-authorization.json") if err != nil { t.Fatal(err) } - if _, err := ValidateAuthorization(invalid, snapshot, "tenant-1", "tenant-demo-key", "egress-mock", time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC)); err == nil { - t.Fatal("expected revoked authorization rejection") - } - valid, err := contracts.Files.ReadFile("upstream/v1/examples/ai-authorization.json") - if err != nil { - t.Fatal(err) - } - if _, err := ValidateAuthorization(valid, snapshot, "tenant-other", "tenant-demo-key", "egress-mock", time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC)); err == nil { - t.Fatal("expected tenant binding rejection") - } - if _, err := ValidateAuthorization(valid, snapshot, "tenant-1", "tenant-demo-key", "egress-mock", time.Date(2026, 9, 18, 0, 2, 0, 0, time.UTC)); err == nil { - t.Fatal("expected expired authorization rejection") + if _, err := DecodeBoundAuthorization(old, snapshot, "tenant-1", "tenant-demo-key"); err == nil { + t.Fatal("legacy egress-pool authorization field unexpectedly accepted") } } diff --git a/internal/configread/client_test.go b/internal/configread/client_test.go index a8c2705..763d736 100644 --- a/internal/configread/client_test.go +++ b/internal/configread/client_test.go @@ -67,6 +67,21 @@ func TestClientReadTaskRequestsAndValidatesThreeConfigEndpoints(t *testing.T) { } } +func TestClientReadTaskRejectsRemovedEgressPoolField(t *testing.T) { + fixtures := validConfigFixtures(t) + var sip map[string]any + if err := json.Unmarshal(fixtures[configReadPath+"/sip"], &sip); err != nil { + t.Fatal(err) + } + sip["trunks"].([]any)[0].(map[string]any)["egress_pool_id"] = "egress-mock" + 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("removed egress pool field accepted by SIP config") + } +} + func TestClientReadTaskRejectsDuplicateSIPTrunksAndCallers(t *testing.T) { for _, tc := range []struct { name string @@ -171,7 +186,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.2.json"), + configReadPath + "/sip": readConfigFixture(t, "config-read-sip-v0.3.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 5b1b674..a8ef019 100644 --- a/internal/contract/contract.go +++ b/internal/contract/contract.go @@ -69,13 +69,17 @@ func ValidateEvent(raw []byte) error { } func ValidateLocalConfigRead(raw []byte) error { - return validateLocalSchema("config-read-v0.2.schema.json", raw) + return validateLocalSchema("config-read-v0.3.schema.json", raw) } func ValidateLocalTaskDiscoveryV04(raw []byte) error { return validateLocalSchema("task-discovery-v0.4-proposal.schema.json", raw) } +func ValidateLocalAIAuthorization(raw []byte) error { + return validateLocalSchema("ai-authorization-v0.2.schema.json", raw) +} + func ValidateLocalTaskControlV04(raw []byte) error { return validateLocalSchema("task-control-v0.4-proposal.schema.json", raw) } diff --git a/internal/contract/schema.go b/internal/contract/schema.go index fbaf0c3..a53104b 100644 --- a/internal/contract/schema.go +++ b/internal/contract/schema.go @@ -64,7 +64,7 @@ func localSchemaVersion(name string) string { return "v0.1" case "config-read-v0.2.schema.json": return "v0.2" - case "task-discovery-v0.3-proposal.schema.json": + case "config-read-v0.3.schema.json", "ai-authorization-v0.2.schema.json", "static-cell-artifact-v0.2.schema.json", "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": return "v0.4" diff --git a/internal/contract/static_artifact.go b/internal/contract/static_artifact.go index d8c9f60..864bc0b 100644 --- a/internal/contract/static_artifact.go +++ b/internal/contract/static_artifact.go @@ -6,7 +6,7 @@ import ( ) // StaticCellArtifact is the management-approved, immutable Cell/SIP hand-off -// artifact. Its JSON shape is owned by static-cell-artifact.schema.json; this +// artifact. Its project-local JSON shape is static-cell-artifact-v0.2.schema.json; this // type only provides a typed boundary after schema validation. type StaticCellArtifact struct { ArtifactID string `json:"artifact_id"` @@ -29,7 +29,6 @@ type StaticCellArtifact struct { type StaticTrunk struct { TrunkID string `json:"trunk_id"` ProviderID string `json:"provider_id"` - EgressPoolID string `json:"egress_pool_id"` Codec string `json:"codec"` CallerProfileIDs []string `json:"caller_profile_ids"` DialPrefix string `json:"dial_prefix"` @@ -79,22 +78,21 @@ type StaticLoadEvidence struct { // Empty string/slice values leave the corresponding optional check disabled; // the source contract remains mandatory and is always validated first. type StaticArtifactExpectation struct { - CellID string - Mode string - SourceRelease string - SourceDigest string - ConfigSHA256 string - MinimumRevision uint64 - AllowedEgressPoolIDs []string - RequiredTrunkIDs []string + CellID string + Mode string + SourceRelease string + SourceDigest string + ConfigSHA256 string + MinimumRevision uint64 + RequiredTrunkIDs []string } -// ValidateStaticArtifact validates the imported artifact schema and then +// ValidateStaticArtifact validates the versioned artifact schema and then // applies the local Cell hand-off bindings. It deliberately does not claim // that Asterisk has loaded the artifact: load_evidence.status is explicitly // "not-yet-loaded" in the contract until an independent load check exists. func ValidateStaticArtifact(raw []byte, expected StaticArtifactExpectation) (StaticCellArtifact, error) { - if err := ValidateSourceSchema("static-cell-artifact.schema.json", raw); err != nil { + if err := validateLocalSchema("static-cell-artifact-v0.2.schema.json", raw); err != nil { return StaticCellArtifact{}, err } @@ -121,10 +119,6 @@ func ValidateStaticArtifact(raw []byte, expected StaticArtifactExpectation) (Sta return StaticCellArtifact{}, fmt.Errorf("static artifact revision %d is older than required %d", artifact.Revision, expected.MinimumRevision) } - allowedEgress := make(map[string]struct{}, len(expected.AllowedEgressPoolIDs)) - for _, egressPoolID := range expected.AllowedEgressPoolIDs { - allowedEgress[egressPoolID] = struct{}{} - } requiredTrunks := make(map[string]struct{}, len(expected.RequiredTrunkIDs)) for _, trunkID := range expected.RequiredTrunkIDs { requiredTrunks[trunkID] = struct{}{} @@ -135,11 +129,6 @@ func ValidateStaticArtifact(raw []byte, expected StaticArtifactExpectation) (Sta return StaticCellArtifact{}, fmt.Errorf("static artifact contains duplicate trunk_id %q", trunk.TrunkID) } seenTrunks[trunk.TrunkID] = struct{}{} - if len(allowedEgress) != 0 { - if _, ok := allowedEgress[trunk.EgressPoolID]; !ok { - return StaticCellArtifact{}, fmt.Errorf("static artifact trunk %q uses disallowed egress pool %q", trunk.TrunkID, trunk.EgressPoolID) - } - } if _, required := requiredTrunks[trunk.TrunkID]; required && !trunk.Enabled { return StaticCellArtifact{}, fmt.Errorf("required static artifact trunk %q is disabled", trunk.TrunkID) } diff --git a/internal/contract/static_artifact_test.go b/internal/contract/static_artifact_test.go index 9e69f10..c9aaa8b 100644 --- a/internal/contract/static_artifact_test.go +++ b/internal/contract/static_artifact_test.go @@ -8,20 +8,19 @@ import ( ) func TestValidateStaticArtifactBindsCellAndTrunks(t *testing.T) { - raw, err := contracts.Read("examples/static-cell-artifact.json") + raw, err := contracts.Files.ReadFile("local/v0.3/examples/static-cell-artifact-v0.2.json") if err != nil { t.Fatal(err) } artifact, err := ValidateStaticArtifact(raw, StaticArtifactExpectation{ - CellID: "cell-a", - Mode: "mock", - SourceRelease: "management-snapshot-1", - SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - MinimumRevision: 1, - AllowedEgressPoolIDs: []string{"egress-mock"}, - RequiredTrunkIDs: []string{"trunk-mock"}, + CellID: "cell-a", + Mode: "mock", + SourceRelease: "management-snapshot-1", + SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + MinimumRevision: 1, + RequiredTrunkIDs: []string{"trunk-mock"}, }) if err != nil { t.Fatalf("valid artifact rejected: %v", err) @@ -32,18 +31,17 @@ func TestValidateStaticArtifactBindsCellAndTrunks(t *testing.T) { } func TestValidateRealStaticArtifact(t *testing.T) { - raw, err := contracts.Read("examples/static-cell-artifact-real-v1.json") + raw, err := contracts.Files.ReadFile("local/v0.3/examples/static-cell-artifact-real-v2.json") if err != nil { t.Fatal(err) } artifact, err := ValidateStaticArtifact(raw, StaticArtifactExpectation{ - CellID: "cell-single", - Mode: "real", - SourceRelease: "asterisk-22.10.1-native-v1", - SourceDigest: "68006a1a8efed288be4ca4a2ae3cb9554a31d733eac08eaacf4c646c95faf74d", - ConfigSHA256: "89d2686d0d1ca60159c3c6bd725dc9e6f511cbdb56bf6ce7b65ca7d4dc3f2d60", - AllowedEgressPoolIDs: []string{"egress-single"}, - RequiredTrunkIDs: []string{"provider-second"}, + CellID: "cell-single", + Mode: "real", + SourceRelease: "asterisk-22.10.1-native-v1", + SourceDigest: "68006a1a8efed288be4ca4a2ae3cb9554a31d733eac08eaacf4c646c95faf74d", + ConfigSHA256: "89d2686d0d1ca60159c3c6bd725dc9e6f511cbdb56bf6ce7b65ca7d4dc3f2d60", + RequiredTrunkIDs: []string{"provider-second"}, }) if err != nil { t.Fatalf("valid real artifact rejected: %v", err) @@ -57,18 +55,17 @@ func TestValidateRealStaticArtifact(t *testing.T) { } func TestValidateStaticArtifactRejectsBindingViolations(t *testing.T) { - raw, err := contracts.Read("examples/static-cell-artifact.json") + raw, err := contracts.Files.ReadFile("local/v0.3/examples/static-cell-artifact-v0.2.json") if err != nil { t.Fatal(err) } base := func() StaticArtifactExpectation { return StaticArtifactExpectation{ - CellID: "cell-a", - Mode: "mock", - SourceRelease: "management-snapshot-1", - SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - AllowedEgressPoolIDs: []string{"egress-mock"}, + CellID: "cell-a", + Mode: "mock", + SourceRelease: "management-snapshot-1", + SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", } } @@ -84,11 +81,6 @@ func TestValidateStaticArtifactRejectsBindingViolations(t *testing.T) { return e }()}, {name: "old revision", expected: func() StaticArtifactExpectation { e := base(); e.MinimumRevision = 2; return e }()}, - {name: "disallowed egress", expected: func() StaticArtifactExpectation { - e := base() - e.AllowedEgressPoolIDs = []string{"egress-other"} - return e - }()}, {name: "missing required trunk", expected: func() StaticArtifactExpectation { e := base() e.RequiredTrunkIDs = []string{"trunk-required"} @@ -119,8 +111,18 @@ func TestValidateStaticArtifactRejectsBindingViolations(t *testing.T) { } } -func TestValidateStaticArtifactAlwaysChecksSourceSchema(t *testing.T) { - raw, err := contracts.Read("examples/static-cell-artifact.json") +func TestValidateStaticArtifactRejectsRemovedEgressPoolField(t *testing.T) { + old, err := contracts.Files.ReadFile("upstream/v1/examples/static-cell-artifact.json") + if err != nil { + t.Fatal(err) + } + if _, err := ValidateStaticArtifact(old, StaticArtifactExpectation{}); err == nil { + t.Fatal("legacy egress-pool field unexpectedly accepted") + } +} + +func TestValidateStaticArtifactAlwaysChecksLocalSchema(t *testing.T) { + raw, err := contracts.Files.ReadFile("local/v0.3/examples/static-cell-artifact-v0.2.json") if err != nil { t.Fatal(err) } diff --git a/internal/dispatcher/dial_policy_test.go b/internal/dispatcher/dial_policy_test.go index 9946544..b4dbfb5 100644 --- a/internal/dispatcher/dial_policy_test.go +++ b/internal/dispatcher/dial_policy_test.go @@ -20,7 +20,7 @@ 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.2.json") + sip := localConfigFixture(t, "config-read-sip-v0.3.json") var document map[string]any if err := json.Unmarshal(sip, &document); err != nil { t.Fatal(err) diff --git a/internal/dispatcher/local_flow_test.go b/internal/dispatcher/local_flow_test.go index e257de5..f8e1bb3 100644 --- a/internal/dispatcher/local_flow_test.go +++ b/internal/dispatcher/local_flow_test.go @@ -18,18 +18,17 @@ import ( func TestLocalContractBackedFlowEvidence(t *testing.T) { now := time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC) - artifactRaw, err := contracts.Read("examples/static-cell-artifact.json") + artifactRaw, err := contracts.Files.ReadFile("local/v0.3/examples/static-cell-artifact-v0.2.json") if err != nil { t.Fatal(err) } if _, err := contract.ValidateStaticArtifact(artifactRaw, contract.StaticArtifactExpectation{ - CellID: "cell-a", - Mode: "mock", - SourceRelease: "management-snapshot-1", - SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - AllowedEgressPoolIDs: []string{"egress-mock"}, - RequiredTrunkIDs: []string{"trunk-mock"}, + CellID: "cell-a", + Mode: "mock", + SourceRelease: "management-snapshot-1", + SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + RequiredTrunkIDs: []string{"trunk-mock"}, }); err != nil { t.Fatal(err) } @@ -42,11 +41,11 @@ func TestLocalContractBackedFlowEvidence(t *testing.T) { if err != nil { t.Fatal(err) } - authorizationRaw, err := contracts.Files.ReadFile("upstream/v1/examples/ai-authorization.json") + authorizationRaw, err := contracts.Files.ReadFile("local/v0.3/examples/ai-authorization-v0.2.json") if err != nil { t.Fatal(err) } - if _, err := ai.ValidateAuthorization(authorizationRaw, snapshot, "tenant-1", "tenant-demo-key", "egress-mock", now); err != nil { + if _, err := ai.ValidateAuthorization(authorizationRaw, snapshot, "tenant-1", "tenant-demo-key", now); err != nil { t.Fatal(err) } diff --git a/internal/dispatcher/local_v01_integration_test.go b/internal/dispatcher/local_v01_integration_test.go index e69b5e3..935d532 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.2.json"), + "/internal/v1/dispatcher/sip": localConfigFixture(t, "config-read-sip-v0.3.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 5f2996a..860fcb0 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.2.json")))) + mux.HandleFunc("/internal/v1/dispatcher/sip", localConfigResponse(string(localConfigFixture(t, "config-read-sip-v0.3.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.2.json")))) + mux.HandleFunc("/internal/v1/dispatcher/sip", localConfigResponse(string(localConfigFixture(t, "config-read-sip-v0.3.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/"):] @@ -673,7 +673,7 @@ func localConfigFixture(t *testing.T, name string) []byte { if err != nil { t.Fatal(err) } - if name == "config-read-sip-v0.2.json" { + if name == "config-read-sip-v0.3.json" { var response map[string]any if err := json.Unmarshal(body, &response); err != nil { t.Fatal(err) diff --git a/internal/rpc/ai_authorization_test.go b/internal/rpc/ai_authorization_test.go index bc9e910..1cb51d1 100644 --- a/internal/rpc/ai_authorization_test.go +++ b/internal/rpc/ai_authorization_test.go @@ -23,7 +23,7 @@ func TestExecutionPermitEnforcesAIAuthorization(t *testing.T) { if err != nil { t.Fatal(err) } - authorizationRaw, err := contracts.Files.ReadFile("upstream/v1/examples/ai-authorization.json") + authorizationRaw, err := contracts.Files.ReadFile("local/v0.3/examples/ai-authorization-v0.2.json") if err != nil { t.Fatal(err) } @@ -31,7 +31,6 @@ func TestExecutionPermitEnforcesAIAuthorization(t *testing.T) { Now: func() time.Time { return time.Date(2026, 9, 18, 0, 0, 30, 0, time.UTC) }, AISnapshotRaw: snapshotRaw, AIAuthorizationRaw: authorizationRaw, - AIEgressPoolID: "egress-mock", }) activateTestServer(t, server) @@ -89,7 +88,7 @@ func TestExecutionPermitRejectsRevokedAIAuthorization(t *testing.T) { if err != nil { t.Fatal(err) } - authorizationRaw, err := contracts.Read("examples/invalid-ai-authorization-revoked.json") + authorizationRaw, err := contracts.Files.ReadFile("local/v0.3/examples/invalid-ai-authorization-revoked-v0.2.json") if err != nil { t.Fatal(err) } @@ -97,7 +96,6 @@ func TestExecutionPermitRejectsRevokedAIAuthorization(t *testing.T) { Now: func() time.Time { return time.Date(2026, 9, 18, 1, 0, 30, 0, time.UTC) }, AISnapshotRaw: snapshotRaw, AIAuthorizationRaw: authorizationRaw, - AIEgressPoolID: "egress-mock", }) activateTestServer(t, server) response, err := server.GetExecutionPermit(context.Background(), &agentv1.GetExecutionPermitRequest{ diff --git a/internal/rpc/server.go b/internal/rpc/server.go index 825c423..3de1309 100644 --- a/internal/rpc/server.go +++ b/internal/rpc/server.go @@ -37,7 +37,6 @@ type ServerOptions struct { StaticArtifactExpected contract.StaticArtifactExpectation AISnapshotRaw []byte AIAuthorizationRaw []byte - AIEgressPoolID string Now func() time.Time RequirePeerCertificate bool PeerAgentIDs map[string]string @@ -65,7 +64,6 @@ type Server struct { staticArtifactError error aiSnapshot ai.Snapshot aiAuthorizationRaw []byte - aiEgressPoolID string aiConfigError error requirePeerCertificate bool peerAgentIDs map[string]string @@ -147,8 +145,8 @@ func NewServer(options ServerOptions) *Server { var aiConfigError error aiConfigured := len(options.AISnapshotRaw) != 0 || len(options.AIAuthorizationRaw) != 0 if aiConfigured { - if len(options.AISnapshotRaw) == 0 || len(options.AIAuthorizationRaw) == 0 || options.AIEgressPoolID == "" { - aiConfigError = errors.New("AI snapshot, authorization and egress pool are required together") + if len(options.AISnapshotRaw) == 0 || len(options.AIAuthorizationRaw) == 0 { + aiConfigError = errors.New("AI snapshot and authorization are required together") } else { aiSnapshot, aiConfigError = ai.Validate(options.AISnapshotRaw) } @@ -164,7 +162,6 @@ func NewServer(options ServerOptions) *Server { staticArtifactError: staticArtifactError, aiSnapshot: aiSnapshot, aiAuthorizationRaw: append([]byte(nil), options.AIAuthorizationRaw...), - aiEgressPoolID: options.AIEgressPoolID, aiConfigError: aiConfigError, requirePeerCertificate: options.RequirePeerCertificate, peerAgentIDs: cloneStringMap(options.PeerAgentIDs), @@ -201,7 +198,7 @@ func (s *Server) validateAIExecution(binding *agentv1.ExecutionBinding, configSH if binding.AgentVersionId != s.aiSnapshot.AgentVersionID { return status.Error(codes.FailedPrecondition, "AI agent version does not match the authorized snapshot") } - if _, err := ai.ValidateAuthorization(s.aiAuthorizationRaw, s.aiSnapshot, binding.TenantId, binding.TenantKey, s.aiEgressPoolID, s.now()); err != nil { + if _, err := ai.ValidateAuthorization(s.aiAuthorizationRaw, s.aiSnapshot, binding.TenantId, binding.TenantKey, s.now()); err != nil { return status.Errorf(codes.PermissionDenied, "AI authorization rejected: %v", err) } return nil diff --git a/internal/rpc/static_artifact_test.go b/internal/rpc/static_artifact_test.go index a695543..33379fa 100644 --- a/internal/rpc/static_artifact_test.go +++ b/internal/rpc/static_artifact_test.go @@ -13,7 +13,7 @@ import ( ) func TestActivationValidatesStaticCellArtifact(t *testing.T) { - raw, err := contracts.Read("examples/static-cell-artifact.json") + raw, err := contracts.Files.ReadFile("local/v0.3/examples/static-cell-artifact-v0.2.json") if err != nil { t.Fatal(err) } @@ -21,13 +21,12 @@ func TestActivationValidatesStaticCellArtifact(t *testing.T) { Now: func() time.Time { return time.Unix(100, 0) }, StaticArtifactRaw: raw, StaticArtifactExpected: contract.StaticArtifactExpectation{ - CellID: "cell-a", - Mode: "mock", - SourceRelease: "management-snapshot-1", - SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - AllowedEgressPoolIDs: []string{"egress-mock"}, - RequiredTrunkIDs: []string{"trunk-mock"}, + CellID: "cell-a", + Mode: "mock", + SourceRelease: "management-snapshot-1", + SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + RequiredTrunkIDs: []string{"trunk-mock"}, }, }) meta := testMeta("activate-static", "", 0) @@ -46,7 +45,7 @@ func TestActivationValidatesStaticCellArtifact(t *testing.T) { } func TestActivationRejectsInvalidStaticCellArtifact(t *testing.T) { - raw, err := contracts.Read("examples/static-cell-artifact.json") + raw, err := contracts.Files.ReadFile("local/v0.3/examples/static-cell-artifact-v0.2.json") if err != nil { t.Fatal(err) } diff --git a/internal/store/ai_authorization.go b/internal/store/ai_authorization.go index a56bad0..79b3af4 100644 --- a/internal/store/ai_authorization.go +++ b/internal/store/ai_authorization.go @@ -11,10 +11,7 @@ import ( // LoadAuthorizedAI never treats a cached configuration as permission to run. // The latest received reply governs admission; rejection, revocation and expiry // cannot fall back to an earlier successful reply. -func (s *Store) LoadAuthorizedAI(tenantID, tenantKey, version, egressPool string) (ai.Snapshot, []byte, error) { - if egressPool == "" { - return ai.Snapshot{}, nil, errors.New("AI admission requires the deployment egress pool") - } +func (s *Store) LoadAuthorizedAI(tenantID, tenantKey, version string) (ai.Snapshot, []byte, error) { snapshot, err := s.LoadAIConfig(tenantID, tenantKey, version) if err != nil { return ai.Snapshot{}, nil, err @@ -38,7 +35,7 @@ func (s *Store) LoadAuthorizedAI(tenantID, tenantKey, version, egressPool string if err := json.Unmarshal(response.Payload, &payload); err != nil { return ai.Snapshot{}, nil, err } - authorization, err := ai.ValidateAuthorization(payload.Authorization, snapshot, tenantID, tenantKey, egressPool, s.now()) + authorization, err := ai.ValidateAuthorization(payload.Authorization, snapshot, tenantID, tenantKey, s.now()) if err != nil { return ai.Snapshot{}, nil, err } diff --git a/internal/store/ai_authorization_test.go b/internal/store/ai_authorization_test.go index 0e9c70f..9679484 100644 --- a/internal/store/ai_authorization_test.go +++ b/internal/store/ai_authorization_test.go @@ -22,6 +22,7 @@ func validAIReply(t *testing.T, now time.Time) []byte { } payload := message["payload"].(map[string]any) authorization := payload["authorization"].(map[string]any) + delete(authorization, "allowed_egress_pool_ids") authorization["config_sha256"] = payload["snapshot"].(map[string]any)["content_sha256"] authorization["issued_at"] = now.Add(-time.Second).Format(time.RFC3339Nano) authorization["expires_at"] = now.Add(time.Minute).Format(time.RFC3339Nano) @@ -57,18 +58,12 @@ func TestCachedAIRequiresLiveBoundAuthorization(t *testing.T) { if err := s.StoreAIConfigResponse(validAIReply(t, now), route.InboundKey); err != nil { t.Fatal(err) } - snapshot, authorization, err := s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a", "egress-mock") + snapshot, authorization, err := s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a") if err != nil || len(authorization) == 0 || snapshot.AgentVersionID != "version-a" { t.Fatalf("authorized cache: %v", err) } - if _, _, err := s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a", "different-pool"); err == nil { - t.Fatal("egress policy bypass") - } - if _, _, err := s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a", ""); err == nil { - t.Fatal("missing egress policy accepted") - } now = now.Add(time.Hour) - if _, _, err := s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a", "egress-mock"); err == nil { + if _, _, err := s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a"); err == nil { t.Fatal("expired cached authorization admitted work") } } diff --git a/internal/store/ai_revocation_test.go b/internal/store/ai_revocation_test.go index f3b510e..60b0ee1 100644 --- a/internal/store/ai_revocation_test.go +++ b/internal/store/ai_revocation_test.go @@ -63,7 +63,7 @@ func TestRevokedAuthorizationCannotReappearUnderNewRequest(t *testing.T) { if err := s.StoreAIConfigResponse(responseRaw, route.InboundKey); err != nil { t.Fatal(err) } - _, _, err = s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a", "egress-mock") + _, _, err = s.LoadAuthorizedAI("tenant-a", "tenant-a", "version-a") if step == 1 || step == 2 { if err == nil { t.Fatalf("revoked grant admitted at step %d", step) diff --git a/scripts/validate-local-contracts.py b/scripts/validate-local-contracts.py index 33568fa..55db40e 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 or path.name.endswith("-v0.2.json"): + if "invalid" in path.name or path == STATUS_FIXTURE or path.name.endswith(("-v0.2.json", "-v0.3.json")): continue validators["config-read"].validate(load_json(path)) positive_counts["config-read"] += 1 @@ -162,7 +162,7 @@ invalid_prefixes = { negative_counts = {name: 0 for name in SCHEMA_PATHS} for path in sorted(EXAMPLES.glob("*-invalid-*.json")): name = next((schema for prefix, schema in invalid_prefixes.items() if path.name.startswith(prefix)), None) - if name is None or (name == "task-discovery" and not path.name.endswith("-v0.1.json")): + if name is None or (name == "task-discovery" and not path.name.endswith("-v0.1.json")) or (name == "config-read" and path.name.endswith(("-v0.2.json", "-v0.3.json"))): continue sample = load_json(path) try: @@ -199,7 +199,7 @@ def validate_manifest(): path.relative_to(ROOT).as_posix() for path in EXAMPLES.glob("*.json") if (path.name.startswith(("config-read-", "command-next-", "call-result-")) - and not path.name.endswith("-v0.2.json")) + and not path.name.endswith(("-v0.2.json", "-v0.3.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)] @@ -275,8 +275,9 @@ 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, "config-read.v0.2": "config-read-sip-v0.2", "task-discovery.v0.2-proposal": "task-discovery-v0.2"} +proposal_versions = {**versions, "config-read.v0.2": "config-read-sip-v0.2", "config-read.v0.3": "config-read-sip-v0.3", "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()) +validators["config-read-sip-v0.3"] = Draft202012Validator(load_json(ROOT / "docs/contracts/config-read-v0.3.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: @@ -344,6 +345,48 @@ for entry in sip_entries: 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") +# Independent egress-pool fields are absent from all three current project-local inputs. +new_contracts = [ + ("config-read-v0.3", "config-read-sip-v0.3", "config-read-sip-invalid-egress-v0.3"), + ("ai-authorization-v0.2", "ai-authorization-v0.2", "ai-authorization-invalid-egress-v0.2"), + ("static-cell-artifact-v0.2", "static-cell-artifact-v0.2", "static-cell-artifact-invalid-egress-v0.2"), +] +new_artifacts = set() +for schema_name, positive_name, negative_name in new_contracts: + schema_path = ROOT / f"docs/contracts/{schema_name}.schema.json" + embedded_path = ROOT / f"contracts/local/v0.3/{schema_name}.schema.json" + if embedded_path.read_bytes() != schema_path.read_bytes(): + raise SystemExit(f"embedded schema differs from source: {schema_name}") + schema = load_json(schema_path) + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema, registry=registry, format_checker=FormatChecker()) + positive_path = EXAMPLES / f"{positive_name}.json" + negative_path = EXAMPLES / f"{negative_name}.json" + validator.validate(load_json(positive_path)) + embedded_example = ROOT / f"contracts/local/v0.3/examples/{positive_name}.json" + if embedded_example.read_bytes() != positive_path.read_bytes(): + raise SystemExit(f"embedded example differs from source: {positive_name}") + try: + validator.validate(load_json(negative_path)) + except ValidationError: + pass + else: + raise SystemExit(f"removed egress-pool field unexpectedly valid: {negative_name}") + new_artifacts.update(path.relative_to(ROOT).as_posix() for path in (schema_path, positive_path, negative_path)) +new_manifest = load_json(ROOT / "docs/contracts/local-contract-manifest-v0.6.json") +if (new_manifest.get("manifest_version") != "local-contract-manifest.v0.6" + or new_manifest.get("hash_algorithm") != "SHA-256" + or new_manifest.get("source", {}).get("path") != "docs/contracts/config-read-fields-v0.3-proposal.md"): + raise SystemExit("invalid egress-pool removal manifest header") +new_entries = [new_manifest["source"], *new_manifest.get("artifacts", [])] +if len(new_entries) != 10 or {entry.get("path") for entry in new_entries[1:]} != new_artifacts: + raise SystemExit("egress-pool removal manifest artifact set mismatch") +for entry in new_entries: + path = ROOT / entry["path"] + if hashlib.sha256(path.read_bytes()).hexdigest() != entry.get("sha256"): + raise SystemExit(f"egress-pool removal manifest SHA-256 mismatch: {entry['path']}") +print("No-egress-pool contracts: positive=3, negative=3, manifest files=10") + # 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"