remove independent egress pool configuration and authorization
This commit is contained in:
@@ -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映射另行核验,不猜字段。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
-3
@@ -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 联调证据,不能宣称已对外切换。
|
||||
@@ -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
@@ -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 模式
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user