diff --git a/contracts/contracts.go b/contracts/contracts.go index bae4b10..5e5ca7c 100644 --- a/contracts/contracts.go +++ b/contracts/contracts.go @@ -11,13 +11,13 @@ import ( // Files contains pinned upstream schemas and the active local contract versions. // Runtime code never reads a checkout or resolves schema refs online. // -//go:embed upstream local/v0.1 local/v0.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)) diff --git a/contracts/local/v0.2/task-discovery-v0.2-proposal.schema.json b/contracts/local/v0.2/task-discovery-v0.2-proposal.schema.json deleted file mode 100644 index 88b9dff..0000000 --- a/contracts/local/v0.2/task-discovery-v0.2-proposal.schema.json +++ /dev/null @@ -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} - } - } - } - } - } -} diff --git a/contracts/local/v0.3/task-discovery-v0.3-proposal.schema.json b/contracts/local/v0.3/task-discovery-v0.3-proposal.schema.json new file mode 100644 index 0000000..d753688 --- /dev/null +++ b/contracts/local/v0.3/task-discovery-v0.3-proposal.schema.json @@ -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} + } + } + } + } + } +} diff --git a/docs/contracts/examples/task-discovery-empty-v0.3.json b/docs/contracts/examples/task-discovery-empty-v0.3.json new file mode 100644 index 0000000..4807d0f --- /dev/null +++ b/docs/contracts/examples/task-discovery-empty-v0.3.json @@ -0,0 +1,6 @@ +{ + "schema_version": "task-discovery.v0.3-proposal", + "dispatcher_id": "11111111-1111-4111-8111-111111111111", + "tasks": [], + "next_cursor": "4" +} diff --git a/docs/contracts/examples/task-discovery-http-statuses-v0.3.json b/docs/contracts/examples/task-discovery-http-statuses-v0.3.json new file mode 100644 index 0000000..9a1eee6 --- /dev/null +++ b/docs/contracts/examples/task-discovery-http-statuses-v0.3.json @@ -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"}}} + ] +} diff --git a/docs/contracts/examples/task-discovery-invalid-changes-v0.3.json b/docs/contracts/examples/task-discovery-invalid-changes-v0.3.json new file mode 100644 index 0000000..96f50be --- /dev/null +++ b/docs/contracts/examples/task-discovery-invalid-changes-v0.3.json @@ -0,0 +1,7 @@ +{ + "schema_version": "task-discovery.v0.3-proposal", + "dispatcher_id": "11111111-1111-4111-8111-111111111111", + "tasks": [], + "next_cursor": "2", + "changes": [] +} diff --git a/docs/contracts/examples/task-discovery-invalid-cursor-v0.3.json b/docs/contracts/examples/task-discovery-invalid-cursor-v0.3.json new file mode 100644 index 0000000..6b19f0d --- /dev/null +++ b/docs/contracts/examples/task-discovery-invalid-cursor-v0.3.json @@ -0,0 +1,6 @@ +{ + "schema_version": "task-discovery.v0.3-proposal", + "dispatcher_id": "11111111-1111-4111-8111-111111111111", + "tasks": [], + "next_cursor": "04" +} diff --git a/docs/contracts/examples/task-discovery-page-v0.3.json b/docs/contracts/examples/task-discovery-page-v0.3.json new file mode 100644 index 0000000..e3f297d --- /dev/null +++ b/docs/contracts/examples/task-discovery-page-v0.3.json @@ -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" +} diff --git a/docs/contracts/examples/task-discovery-removed-v0.3.json b/docs/contracts/examples/task-discovery-removed-v0.3.json new file mode 100644 index 0000000..92fdfbb --- /dev/null +++ b/docs/contracts/examples/task-discovery-removed-v0.3.json @@ -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" +} diff --git a/docs/contracts/examples/task-discovery-updated-v0.3.json b/docs/contracts/examples/task-discovery-updated-v0.3.json new file mode 100644 index 0000000..c0e2158 --- /dev/null +++ b/docs/contracts/examples/task-discovery-updated-v0.3.json @@ -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" +} diff --git a/docs/contracts/local-contract-manifest-v0.3.json b/docs/contracts/local-contract-manifest-v0.3.json new file mode 100644 index 0000000..5fdbf16 --- /dev/null +++ b/docs/contracts/local-contract-manifest-v0.3.json @@ -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"} + ] +} diff --git a/docs/contracts/task-discovery-v0.3-proposal.schema.json b/docs/contracts/task-discovery-v0.3-proposal.schema.json new file mode 100644 index 0000000..d753688 --- /dev/null +++ b/docs/contracts/task-discovery-v0.3-proposal.schema.json @@ -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} + } + } + } + } + } +} diff --git a/docs/thirds/v0.3.md b/docs/thirds/v0.3.md new file mode 100644 index 0000000..1df33e2 --- /dev/null +++ b/docs/thirds/v0.3.md @@ -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=` 由指定 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 数据保留及灾备后序号连续性未签收,不能声称容量有界或真实联调/生产可用。 diff --git a/internal/contract/contract.go b/internal/contract/contract.go index c8a60ec..89485d6 100644 --- a/internal/contract/contract.go +++ b/internal/contract/contract.go @@ -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 { diff --git a/internal/contract/schema.go b/internal/contract/schema.go index c1ae03e..51fb0f6 100644 --- a/internal/contract/schema.go +++ b/internal/contract/schema.go @@ -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 "" } diff --git a/internal/contract/schema_test.go b/internal/contract/schema_test.go index 7a433e1..fad9299 100644 --- a/internal/contract/schema_test.go +++ b/internal/contract/schema_test.go @@ -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) + } } } diff --git a/scripts/generate-local-contract-bundle.sh b/scripts/generate-local-contract-bundle.sh index a834ef8..4eeeab6 100644 --- a/scripts/generate-local-contract-bundle.sh +++ b/scripts/generate-local-contract-bundle.sh @@ -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/ diff --git a/scripts/validate-local-contracts.py b/scripts/validate-local-contracts.py index 449a86e..369aa61 100644 --- a/scripts/validate-local-contracts.py +++ b/scripts/validate-local-contracts.py @@ -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.