remove independent egress pool configuration and authorization

This commit is contained in:
2026-09-28 19:01:44 +08:00
parent 15c72a55ee
commit f5c2d6a920
43 changed files with 1899 additions and 167 deletions
+2 -2
View File
@@ -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映射另行核验,不猜字段。
+1 -1
View File
@@ -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)
}
@@ -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"
]
}
}
]
}
@@ -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)$"}
}
}
}
}
@@ -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
}
@@ -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": []
}
}
}]
}
@@ -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
}
@@ -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"
@@ -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
}
}
}
@@ -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"
]
}
}
]
}
@@ -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
}
}
}
@@ -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"
]
}
}
]
}
@@ -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 联调证据,不能宣称已对外切换。
+173
View File
@@ -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)$"}
}
}
}
}
@@ -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
}
@@ -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
}
@@ -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": []
}
}
}]
}
@@ -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": []
}
}
}]
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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"},
@@ -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"}
@@ -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"}
]
}
@@ -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"
]
}
}
]
}
+4 -5
View File
@@ -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 模式
+14 -27
View File
@@ -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
}
+16 -20
View File
@@ -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")
}
}
+16 -1
View File
@@ -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"),
+5 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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"
+10 -21
View File
@@ -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)
}
+33 -31
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+9 -10
View File
@@ -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)
}
@@ -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"),
}
+3 -3
View File
@@ -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)
+2 -4
View File
@@ -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{
+3 -6
View File
@@ -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
+8 -9
View File
@@ -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)
}
+2 -5
View File
@@ -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
}
+3 -8
View File
@@ -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")
}
}
+1 -1
View File
@@ -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)
+47 -4
View File
@@ -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"