Freeze project-local task discovery v0.3 contract

This commit is contained in:
2026-09-26 21:36:22 +08:00
parent 0ea1639cbc
commit 034a31c567
18 changed files with 315 additions and 110 deletions
+2 -2
View File
@@ -11,13 +11,13 @@ import (
// Files contains pinned upstream schemas and the active local contract versions.
// Runtime code never reads a checkout or resolves schema refs online.
//
//go:embed upstream local/v0.1 local/v0.2
//go:embed upstream local/v0.1 local/v0.3
var Files embed.FS
const SourceCommit = "v1"
func ReadLocal(version, name string) ([]byte, error) {
if (version != "v0.1" && version != "v0.2") || name == "" || path.Base(name) != name || !strings.HasSuffix(name, ".schema.json") {
if (version != "v0.1" && version != "v0.3") || name == "" || path.Base(name) != name || !strings.HasSuffix(name, ".schema.json") {
return nil, fmt.Errorf("invalid project-local schema %q/%q", version, name)
}
return Files.ReadFile(path.Join("local", version, name))
@@ -1,98 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://go-sip.local/contracts/proposals/task-discovery-v0.2-proposal.schema.json",
"title": "Project-local single-response Dispatcher task discovery; external compatibility unverified",
"oneOf": [
{"$ref": "#/$defs/snapshot_response"},
{"$ref": "#/$defs/changes_response"},
{"$ref": "#/$defs/error_response"}
],
"$defs": {
"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}$"
},
"cursor": {
"type": "string", "minLength": 1, "maxLength": 512,
"$comment": "Opaque SaaS change watermark; never compare numerically or derive from task_id."
},
"task": {
"type": "object", "additionalProperties": false,
"required": ["task_id", "tenant_id", "tenant_key", "status", "task_revision"],
"properties": {
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
"tenant_id": {"type": "string", "minLength": 1, "maxLength": 128},
"tenant_key": {"type": "string", "minLength": 1, "maxLength": 196,
"$comment": "Also enforce the UTF-8 byte limit and tenant_id mapping in business logic."},
"status": {"enum": ["running", "paused", "stopped", "finished"]},
"task_revision": {"type": "integer", "minimum": 1}
}
},
"change": {
"oneOf": [
{
"type": "object", "additionalProperties": false,
"required": ["cursor", "operation", "task_id", "tenant_id", "tenant_key", "status", "task_revision"],
"properties": {
"cursor": {"$ref": "#/$defs/cursor"},
"operation": {"enum": ["assigned", "updated"]},
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
"tenant_id": {"type": "string", "minLength": 1, "maxLength": 128},
"tenant_key": {"type": "string", "minLength": 1, "maxLength": 196},
"status": {"enum": ["running", "paused", "stopped", "finished"]},
"task_revision": {"type": "integer", "minimum": 1}
}
},
{
"type": "object", "additionalProperties": false,
"required": ["cursor", "operation", "task_id", "tenant_id", "tenant_key"],
"properties": {
"cursor": {"$ref": "#/$defs/cursor"},
"operation": {"const": "removed"},
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
"tenant_id": {"type": "string", "minLength": 1, "maxLength": 128},
"tenant_key": {"type": "string", "minLength": 1, "maxLength": 196}
}
}
]
},
"snapshot_response": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "dispatcher_id", "cursor", "tasks"],
"properties": {
"schema_version": {"const": "task-discovery.v0.2-proposal"},
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
"cursor": {"$ref": "#/$defs/cursor"},
"tasks": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/task"}}
},
"$comment": "No pagination or queue address in the body. Persist the entire consistent response before advancing the cursor."
},
"changes_response": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "dispatcher_id", "next_cursor", "changes"],
"properties": {
"schema_version": {"const": "task-discovery.v0.2-proposal"},
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
"next_cursor": {"$ref": "#/$defs/cursor"},
"changes": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/change"}}
},
"$comment": "No change: changes=[] and next_cursor=request after. An unrepresentable complete change set requires HTTP 410 cursor_expired, never a partial 200."
},
"error_response": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "resource", "error"],
"properties": {
"schema_version": {"const": "task-discovery.v0.2-proposal"},
"resource": {"const": "error"},
"error": {
"type": "object", "additionalProperties": false,
"required": ["code", "message"],
"properties": {
"code": {"enum": ["invalid_cursor", "cursor_expired", "unauthorized", "dispatcher_not_authorized", "service_unavailable"]},
"message": {"type": "string", "minLength": 1, "maxLength": 256}
}
}
}
}
}
}
@@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://go-sip.local/contracts/proposals/task-discovery-v0.3-proposal.schema.json",
"title": "Project-local event-cursor Dispatcher task pages; external compatibility unverified",
"oneOf": [
{"$ref": "#/$defs/page_response"},
{"$ref": "#/$defs/error_response"}
],
"$defs": {
"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}$"
},
"cursor": {
"type": "string", "pattern": "^(0|[1-9][0-9]{0,19})$",
"$comment": "Canonical decimal uint64 event ID per Dispatcher; semantic parsing enforces the uint64 maximum. Never derived from task_id or expires."
},
"task": {
"type": "object", "additionalProperties": false,
"required": ["task_id", "tenant_id", "tenant_key", "status", "task_revision"],
"properties": {
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
"tenant_id": {"type": "string", "minLength": 1, "maxLength": 128},
"tenant_key": {"type": "string", "minLength": 1, "maxLength": 196,
"$comment": "Also enforce UTF-8 byte limit and tenant_id mapping in business logic."},
"status": {"enum": ["running", "paused", "stopped", "finished", "removed"]},
"task_revision": {"type": "integer", "minimum": 1}
},
"$comment": "Removed tombstones retain original task and tenant identity indefinitely. No per-item event_id or queue location."
},
"page_response": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "dispatcher_id", "next_cursor", "tasks"],
"properties": {
"schema_version": {"const": "task-discovery.v0.3-proposal"},
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
"next_cursor": {"$ref": "#/$defs/cursor"},
"tasks": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/task"}}
},
"$comment": "Initial and later pages share one shape. A nonempty page advances after to its last returned event; an empty page repeats after and signals caught up. Server guarantees ascending complete event positions; client cannot verify ordering inside a page without per-item IDs."
},
"error_response": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "resource", "error"],
"properties": {
"schema_version": {"const": "task-discovery.v0.3-proposal"},
"resource": {"const": "error"},
"error": {
"type": "object", "additionalProperties": false,
"required": ["code", "message"],
"properties": {
"code": {"enum": ["invalid_cursor", "unauthorized", "dispatcher_not_authorized", "service_unavailable"]},
"message": {"type": "string", "minLength": 1, "maxLength": 256}
}
}
}
}
}
}
@@ -0,0 +1,6 @@
{
"schema_version": "task-discovery.v0.3-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tasks": [],
"next_cursor": "4"
}
@@ -0,0 +1,9 @@
{
"fixture_version": "task-discovery-http-statuses.v0.3",
"responses": [
{"status": 400, "body": {"schema_version": "task-discovery.v0.3-proposal", "resource": "error", "error": {"code": "invalid_cursor", "message": "Unknown or malformed event cursor"}}},
{"status": 401, "body": {"schema_version": "task-discovery.v0.3-proposal", "resource": "error", "error": {"code": "unauthorized", "message": "Invalid Dispatcher credentials"}}},
{"status": 403, "body": {"schema_version": "task-discovery.v0.3-proposal", "resource": "error", "error": {"code": "dispatcher_not_authorized", "message": "Dispatcher is not assigned these tasks"}}},
{"status": 503, "body": {"schema_version": "task-discovery.v0.3-proposal", "resource": "error", "error": {"code": "service_unavailable", "message": "Event state is temporarily unavailable"}}}
]
}
@@ -0,0 +1,7 @@
{
"schema_version": "task-discovery.v0.3-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tasks": [],
"next_cursor": "2",
"changes": []
}
@@ -0,0 +1,6 @@
{
"schema_version": "task-discovery.v0.3-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tasks": [],
"next_cursor": "04"
}
@@ -0,0 +1,9 @@
{
"schema_version": "task-discovery.v0.3-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tasks": [
{"task_id": "a01", "tenant_id": "tenant-1", "tenant_key": "tenant-A", "status": "running", "task_revision": 1},
{"task_id": "a02", "tenant_id": "tenant-1", "tenant_key": "tenant-A", "status": "paused", "task_revision": 1}
],
"next_cursor": "2"
}
@@ -0,0 +1,8 @@
{
"schema_version": "task-discovery.v0.3-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tasks": [
{"task_id": "a02", "tenant_id": "tenant-1", "tenant_key": "tenant-A", "status": "removed", "task_revision": 2}
],
"next_cursor": "4"
}
@@ -0,0 +1,8 @@
{
"schema_version": "task-discovery.v0.3-proposal",
"dispatcher_id": "11111111-1111-4111-8111-111111111111",
"tasks": [
{"task_id": "a01", "tenant_id": "tenant-1", "tenant_key": "tenant-A", "status": "paused", "task_revision": 1}
],
"next_cursor": "3"
}
@@ -0,0 +1,19 @@
{
"manifest_version": "local-contract-manifest.v0.3",
"hash_algorithm": "SHA-256",
"source": {"path": "docs/thirds/v0.3.md", "sha256": "2fa8f5a10b1509044b04b4416a44496c5e9a2d9c741e5437862e2edcb92649ce"},
"artifacts": [
{"path": "docs/contracts/task-discovery-v0.3-proposal.schema.json", "sha256": "da8eda2e8f5416b2fb35f68e09a98f37d1417a878e9271b8e6d0d6824b94814c"},
{"path": "docs/contracts/examples/task-discovery-empty-v0.3.json", "sha256": "4b1b03b75662d52b013cb080ceb13160f7811ab4396f07e908dcf684cf06f032"},
{"path": "docs/contracts/examples/task-discovery-http-statuses-v0.3.json", "sha256": "520caf658630055e1354a466767cf4ca6cbc887e6bc00e7182a79b0fa2a925ef"},
{"path": "docs/contracts/examples/task-discovery-invalid-changes-v0.3.json", "sha256": "fdddeae103e0107156ea49bef825226d1be0782a1d4a13cfb6d839522923ab0d"},
{"path": "docs/contracts/examples/task-discovery-invalid-cursor-v0.3.json", "sha256": "a5645ce382388d289cfda043975793a6b0e0be69befd9adb2182ab8db5a7ae5d"},
{"path": "docs/contracts/examples/task-discovery-page-v0.3.json", "sha256": "4ede6ebe75b93c8687f625be05bbedb61471c43303d57aef6ed4b8cbb0fabb44"},
{"path": "docs/contracts/examples/task-discovery-removed-v0.3.json", "sha256": "be1d9d853ad8a7c4ba271821932f16856daf1c63723682bc656ec18721da33d1"},
{"path": "docs/contracts/examples/task-discovery-updated-v0.3.json", "sha256": "76aa5528f4c9362301492282151e7ddd362590ebea096ffef7b82131e9c99435"},
{"path": "docs/contracts/config-read-v0.1.schema.json", "sha256": "d3fbf066295916b5322fff44c98a9e847de592135885ddff057f7f089fa4dfea"},
{"path": "docs/contracts/command-next-v0.1-proposal.schema.json", "sha256": "fcd3ec1d56baa69a22fac76363a533e252658fb3b7a4fe7020f4322d965716de"},
{"path": "docs/contracts/call-result-v0.1-proposal.schema.json", "sha256": "8068508cfd05e35d06b4c1c06bee826104be7d176fd07b6822714704d321bf35"},
{"path": "docs/contracts/mq-topology-v0.1-proposal.json", "sha256": "20c0f69057e283df81823f7ff333e2c1cc7d756a68c10a22fc7d50ad793d53c4"}
]
}
@@ -0,0 +1,59 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://go-sip.local/contracts/proposals/task-discovery-v0.3-proposal.schema.json",
"title": "Project-local event-cursor Dispatcher task pages; external compatibility unverified",
"oneOf": [
{"$ref": "#/$defs/page_response"},
{"$ref": "#/$defs/error_response"}
],
"$defs": {
"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}$"
},
"cursor": {
"type": "string", "pattern": "^(0|[1-9][0-9]{0,19})$",
"$comment": "Canonical decimal uint64 event ID per Dispatcher; semantic parsing enforces the uint64 maximum. Never derived from task_id or expires."
},
"task": {
"type": "object", "additionalProperties": false,
"required": ["task_id", "tenant_id", "tenant_key", "status", "task_revision"],
"properties": {
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
"tenant_id": {"type": "string", "minLength": 1, "maxLength": 128},
"tenant_key": {"type": "string", "minLength": 1, "maxLength": 196,
"$comment": "Also enforce UTF-8 byte limit and tenant_id mapping in business logic."},
"status": {"enum": ["running", "paused", "stopped", "finished", "removed"]},
"task_revision": {"type": "integer", "minimum": 1}
},
"$comment": "Removed tombstones retain original task and tenant identity indefinitely. No per-item event_id or queue location."
},
"page_response": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "dispatcher_id", "next_cursor", "tasks"],
"properties": {
"schema_version": {"const": "task-discovery.v0.3-proposal"},
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
"next_cursor": {"$ref": "#/$defs/cursor"},
"tasks": {"type": "array", "maxItems": 256, "items": {"$ref": "#/$defs/task"}}
},
"$comment": "Initial and later pages share one shape. A nonempty page advances after to its last returned event; an empty page repeats after and signals caught up. Server guarantees ascending complete event positions; client cannot verify ordering inside a page without per-item IDs."
},
"error_response": {
"type": "object", "additionalProperties": false,
"required": ["schema_version", "resource", "error"],
"properties": {
"schema_version": {"const": "task-discovery.v0.3-proposal"},
"resource": {"const": "error"},
"error": {
"type": "object", "additionalProperties": false,
"required": ["code", "message"],
"properties": {
"code": {"enum": ["invalid_cursor", "unauthorized", "dispatcher_not_authorized", "service_unavailable"]},
"message": {"type": "string", "minLength": 1, "maxLength": 256}
}
}
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
# 第三方任务发现事件游标分页 v0.3(项目内提案)
> 仅替换 [`v0.2`](v0.2.md) §2.5 的任务发现目标;其他 SaaS 项目内业务接口仍按 v0.1。设计依据为 [`plan-0926.md`](../plan-0926.md)。本文件、Schema 和 Mock **未经 SaaS/业务签收,不是现网接口或生产合同**;新版未完成切换前 v0.2 仍是本地运行依据。
## 2.5 唯一任务发现路径
`GET /internal/v1/dispatcher/tasks?after=<cursor>` 由指定 Dispatcher 使用既有 `X-DISPATCHER-id` / `X-DISPATCHER-SECRET-KEY` 读取自己的任务。首次无本协议游标时发送 `after=0`;之后只发送已和任务状态一起持久化的 `next_cursor`。`after` 是**该 D 范围内单调、不可复用的事件 ID**,不是任务 ID、页号或任务配置版本。规范形式是无前导零的十进制 uint64 字符串;只允许起点为 `0`。D 身份不可更换来绕过游标。
所有 HTTP 200 均采用同一形状:`schema_version=task-discovery.v0.3-proposal`、`dispatcher_id`、`tasks[]` 和 `next_cursor`。`tasks[]` 中每项为 `task_id`、`tenant_id`、原值 `tenant_key`、`status`、`task_revision`;**没有** `changes`、`operation`、`snapshot`、`mode`、单项 `event_id`、SaaS 队列名或任务页 token。同一任务有更晚的事件时可再次出现。SaaS 对每个任务只需返回**最新状态**,不要求回放所有中间状态;已有任务只有 `status` 变化才触发生命周期动作,其他字段仍须校验身份、版本及归属。任务具体执行配置仍由独立只读接口取得,不由发现列表取代。
SaaS 在响应中按其事件序号升序选取尚未返回的**最新任务记录**。每页至多 256 项,活动任务总量仍受单 D 256 上限约束;撤销墓碑的累计数量不在这个活动上限之内。非空页的 `next_cursor` 必须是**最后一个实际返回任务**的事件 ID,并严格大于请求的 `after`,绝不能前进到尚未返回的更新之后。空页表示本轮追平,`tasks=[]` 且 `next_cursor=after`;D 可在首次追平后开放经核验的新执行,其后约 30 秒再次查询。不得依靠“不足 256 项”断定追平:无论每页实际数量,D 都必须继续读取直到空页。若页内同一任务重复出现、任务身份发生冲突或游标不前进,D 拒绝整页而不是挑一条使用。
**信任边界**:用户确认仅返回响应级 `next_cursor`,不返回每项 `event_id`。D 可以验证游标形式、单调前进、响应身份和任务数据,但**不能独立证明 SaaS 的页内顺序及没有漏项**;SaaS 必须保证选择完整、有序、无跳跃,真实 SaaS 签收及并发分页故障测试属于外部门禁。Mock 正例不代签这一保证。
### 状态和撤销
`status` 只允许 `running`、`paused`、`stopped`、`finished`、`removed`。`removed` 是该 D 归属撤销的**任务墓碑**,必须保留原任务/租户身份;某任务没有出现在本页不表示撤销。SaaS 先持久化 stop/撤销及其必要回执、协调自己拥有的任务队列退役,再发出墓碑;D 收到后停止新接纳,不清理执行恢复、已入队结果、幂等或未知占用,也不自动强挂在途通话。paused 保留原积压;MQ 控制即时执行,不等轮询,也不允许较旧 `running` 解开已持久的 paused/stopped 屏障。
### 一致性、错误和恢复
- 任务页与游标同一 SQLite 事务提交;页提交失败不能推进游标。首次启动/恢复在读到**空页**并完成授权、归属和 SaaS 预建队列核验前不消费执行消息。中途失败、响应无效或网络不可用时保留本地任务、控制、执行事实与错误记录,拒绝新执行;MQ 即时控制仍继续。不能拿空列表推断未返回任务已经 removed。
- 游标**永不过期**:不提供 410 或 `cursor_expired`,也不因时间推移要求 D 自动从零重建。SaaS 必须长期维护每个任务可追溯的最新事件位置和撤销墓碑;对任意曾提交的 D 游标,不能返回遗漏后续状态的伪造空页。`after=0` 的首次分页同样有界而不一次返回全部。序号回退、持久记录丢失或无法证明连续时返回明确错误,D fail-closed 并等待处理,不暗中重置。
- HTTP 错误:400 `invalid_cursor`、401 `unauthorized`、403 `dispatcher_not_authorized`、503 `service_unavailable`;成功只允许 200。返回体按本版本严格 Schema,错误正文不含任务或凭据。具体身份、响应大小与实际 SaaS 系统行为仍待签收。
- 当前 v0.2 游标**不能**重解释为事件 ID;切换前必须核验旧任务、积压与在途执行安全收口。新版只运行一个任务发现消费者,不保留旧 `changes`/410/分页 token 兼容路径或 HTTP→MQ 回退。SaaS 独占建队、绑定和退役,D 只消费预建队列。
## 验收及容量阻断
本地 Mock 要覆盖初始多页、空页、同一任务再出现、撤销、跨页并发更新、重启原游标续读、重复页、事务回滚、暂停/停止竞态和 queue-only 消费;所有关键边界 fail-closed,不重拨。实时控制与最终结果的其他 v0.1 业务合同不变。活动任务 256 上限**不约束永久墓碑**;历史撤销增长、离线追赶时间、SaaS 数据保留及灾备后序号连续性未签收,不能声称容量有界或真实联调/生产可用。
+1 -1
View File
@@ -73,7 +73,7 @@ func ValidateLocalConfigRead(raw []byte) error {
}
func ValidateLocalTaskDiscovery(raw []byte) error {
return validateLocalSchema("task-discovery-v0.2-proposal.schema.json", raw)
return validateLocalSchema("task-discovery-v0.3-proposal.schema.json", raw)
}
func ValidateLocalCommandNext(raw []byte) error {
+2 -2
View File
@@ -62,8 +62,8 @@ func localSchemaVersion(name string) string {
switch name {
case "config-read-v0.1.schema.json", "command-next-v0.1-proposal.schema.json", "call-result-v0.1-proposal.schema.json", "local-mock-recording-failure-v0.1.schema.json":
return "v0.1"
case "task-discovery-v0.2-proposal.schema.json":
return "v0.2"
case "task-discovery-v0.3-proposal.schema.json":
return "v0.3"
default:
return ""
}
+11 -4
View File
@@ -64,10 +64,17 @@ func TestProjectLocalConfigurationSchemasValidatePositivesAndRejectNegatives(t *
if err := ValidateLocalConfigRead(read("config-read-invalid-extra-property-v0.1.json")); err == nil {
t.Fatal("config-read schema accepted an additional property")
}
if err := ValidateLocalTaskDiscovery(read("task-discovery-snapshot-v0.2.json")); err != nil {
t.Fatalf("valid discovery snapshot: %v", err)
page := []byte(`{"schema_version":"task-discovery.v0.3-proposal","dispatcher_id":"11111111-1111-4111-8111-111111111111","next_cursor":"12","tasks":[{"task_id":"22222222-2222-4222-8222-222222222222","tenant_id":"33333333-3333-4333-8333-333333333333","tenant_key":"tenant-A","status":"removed","task_revision":4}]}`)
if err := ValidateLocalTaskDiscovery(page); err != nil {
t.Fatalf("valid discovery event page: %v", err)
}
if err := ValidateLocalTaskDiscovery(read("task-discovery-invalid-queue-v0.2.json")); err == nil {
t.Fatal("task-discovery schema accepted a queue field in the response")
for name, raw := range map[string][]byte{
"old snapshot": read("task-discovery-snapshot-v0.2.json"),
"changes": []byte(`{"schema_version":"task-discovery.v0.3-proposal","dispatcher_id":"11111111-1111-4111-8111-111111111111","next_cursor":"12","tasks":[],"changes":[]}`),
"leading zero cursor": []byte(`{"schema_version":"task-discovery.v0.3-proposal","dispatcher_id":"11111111-1111-4111-8111-111111111111","next_cursor":"012","tasks":[]}`),
} {
if err := ValidateLocalTaskDiscovery(raw); err == nil {
t.Fatalf("task-discovery schema accepted %s", name)
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p contracts/local/v0.1 contracts/local/v0.2
mkdir -p contracts/local/v0.1 contracts/local/v0.3
rm -f contracts/local/v0.1/task-discovery-v0.1-proposal.schema.json
cp docs/contracts/config-read-v0.1.schema.json docs/contracts/command-next-v0.1-proposal.schema.json docs/contracts/call-result-v0.1-proposal.schema.json docs/contracts/local-mock-recording-failure-v0.1.schema.json contracts/local/v0.1/
cp docs/contracts/task-discovery-v0.2-proposal.schema.json contracts/local/v0.2/
cp docs/contracts/task-discovery-v0.3-proposal.schema.json contracts/local/v0.3/
+79 -1
View File
@@ -304,7 +304,85 @@ for entry in [source, *artifacts]:
raise SystemExit(f"invalid v0.2 manifest entry: {entry}")
if not (ROOT / relative).is_file() or hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() != digest:
raise SystemExit(f"v0.2 manifest SHA-256 mismatch: {relative}")
print(f"Task discovery v0.2 proposal: positive={proposal_positive}, negative={proposal_negative}, manifest files={1 + len(artifacts)}")
print(f"Task discovery v0.2 proposal (historical): positive={proposal_positive}, negative={proposal_negative}, manifest files={1 + len(artifacts)}")
# 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"
event_schema = load_json(event_schema_path)
event_embedded = ROOT / "contracts/local/v0.3/task-discovery-v0.3-proposal.schema.json"
if event_embedded.read_bytes() != event_schema_path.read_bytes():
raise SystemExit("embedded v0.3 discovery schema differs from its project source")
Draft202012Validator.check_schema(event_schema)
if schema_versions(event_schema) != {"task-discovery.v0.3-proposal"}:
raise SystemExit("unexpected v0.3 discovery schema version")
event_validator = Draft202012Validator(event_schema, registry=registry, format_checker=FormatChecker())
event_examples = sorted(EXAMPLES.glob("task-discovery-*-v0.3.json"))
event_status_path = EXAMPLES / "task-discovery-http-statuses-v0.3.json"
event_positive = event_negative = 0
for path in event_examples:
if path == event_status_path:
continue
sample = load_json(path)
if "invalid" in path.name:
try:
event_validator.validate(sample)
except ValidationError:
event_negative += 1
else:
raise SystemExit(f"invalid v0.3 discovery fixture passed: {path.relative_to(ROOT)}")
else:
event_validator.validate(sample)
if int(sample["next_cursor"]) > 18446744073709551615:
raise SystemExit(f"v0.3 event cursor exceeds uint64: {path.relative_to(ROOT)}")
event_positive += 1
if sample["tasks"]:
too_many = {**sample, "tasks": [sample["tasks"][0]] * 257}
try:
event_validator.validate(too_many)
except ValidationError:
pass
else:
raise SystemExit("v0.3 discovery page accepted over 256 rows")
event_statuses = load_json(event_status_path)
if event_statuses.get("fixture_version") != "task-discovery-http-statuses.v0.3":
raise SystemExit("invalid v0.3 HTTP status fixture version")
expected_event_errors = {400: "invalid_cursor", 401: "unauthorized", 403: "dispatcher_not_authorized", 503: "service_unavailable"}
seen_event_errors = set()
for entry in event_statuses.get("responses", []):
status, body = entry.get("status"), entry.get("body")
if status not in expected_event_errors or status in seen_event_errors:
raise SystemExit(f"unexpected or duplicate v0.3 HTTP status: {status}")
event_validator.validate(body)
if body.get("error", {}).get("code") != expected_event_errors[status]:
raise SystemExit(f"incorrect v0.3 error for HTTP {status}")
seen_event_errors.add(status)
if seen_event_errors != set(expected_event_errors) or event_positive < 4 or event_negative < 2:
raise SystemExit("missing v0.3 discovery positives, negatives or HTTP statuses")
event_manifest = load_json(ROOT / "docs/contracts/local-contract-manifest-v0.3.json")
if event_manifest.get("manifest_version") != "local-contract-manifest.v0.3" or event_manifest.get("hash_algorithm") != "SHA-256":
raise SystemExit("invalid v0.3 manifest version or hash algorithm")
event_source = event_manifest.get("source", {})
event_artifacts = event_manifest.get("artifacts", [])
required_event_paths = {
event_schema_path.relative_to(ROOT).as_posix(),
*(path.relative_to(ROOT).as_posix() for path in event_examples),
*(SCHEMA_PATHS[name].relative_to(ROOT).as_posix() for name in ("config-read", "command-next", "call-result")),
"docs/contracts/mq-topology-v0.1-proposal.json",
}
event_paths = [entry.get("path") for entry in event_artifacts if isinstance(entry, dict)]
if (event_source.get("path") != event_doc.relative_to(ROOT).as_posix() or not isinstance(event_artifacts, list)
or len(event_paths) != len(event_artifacts) or len(event_paths) != len(set(event_paths))
or set(event_paths) != required_event_paths):
raise SystemExit("v0.3 manifest source/artifacts mismatch")
for entry in [event_source, *event_artifacts]:
relative = Path(entry.get("path", ""))
digest = entry.get("sha256", "")
if relative.is_absolute() or ".." in relative.parts or not re.fullmatch(r"[0-9a-f]{64}", str(digest)):
raise SystemExit(f"invalid v0.3 manifest entry: {entry}")
if not (ROOT / relative).is_file() or hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() != digest:
raise SystemExit(f"v0.3 manifest SHA-256 mismatch: {relative}")
print(f"Task discovery v0.3 proposal: positive={event_positive + len(seen_event_errors)}, negative={event_negative}, manifest files={1 + len(event_artifacts)}")
# The approved Agent→Dispatcher failure fact is Mock-only. Its independent
# manifest must not alter the historical SaaS v0.1 or discovery v0.2 baselines.