From 3ad606b3f5fe721ec63123329bbe9ba8e52b6abd Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 30 Sep 2026 15:41:32 +0800 Subject: [PATCH] fix(release): attest the sole current local contract --- .../saas-dispatcher-implementation.md | 1 + scripts/build-release.sh | 23 +- scripts/check-contracts.sh | 2 +- scripts/check-release-gates-local.sh | 52 +- scripts/generate-local-contract-bundle.sh | 8 - scripts/validate-local-contracts.py | 569 ------------------ 6 files changed, 24 insertions(+), 631 deletions(-) delete mode 100644 scripts/generate-local-contract-bundle.sh delete mode 100644 scripts/validate-local-contracts.py diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index c3f8bbb..ad1d19c 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -128,6 +128,7 @@ - 旧 AI 配置与供应商链路:旧 `Snapshot`/独立 MQ 配置响应/独立授权、旧可变 Mock 与旧供应商流水线已无现行执行者;删除专属实现和测试。当前获批执行仍使用原值 AI 模式及每通话不可变参数;将仍被当前路径使用的 `Mode` 与 `TurnResult` 移至 `internal/ai/turn.go`。先在当前 SDK 隔离测试补齐 LLM 失败与空回答候选不自动重试,再删除旧 SDK 专属测试;ASR-only、完整 AI、关键词、时限和媒体的当前测试继续通过。旧供应商测试的历史通过不能代替真实供应商联调。 - 旧项目内合同装载:测试先复现 `contracts.Files` 将 v0.1–v0.4 的历史本地 Schema 仍嵌入现行二进制;现在仅嵌入获批当前 local Schema/示例和原样上游 v1 来源,移除可从代码访问旧 local 的 `ReadLocal`、旧命令/事件/配置验证层及专属测试,将并发 Schema 缓存保留于当前验证器。项目历史 Schema 文件与外部上游来源仍原样留在仓库,没有改写来源哈希;历史文件不再构成运行时回退。当前合同正反例、上游来源读取和全仓 Go 测试通过,未代表外部 SaaS 已签收。 - 旧 Dispatcher 本地配置解析:旧 `LoadDispatcherFile` 独占读取上游 v1 `dispatcher-config.schema.json`,没有现行 Dispatcher CLI 调用者;删除实现与旧文件专属测试。现行入口仍从部署环境独立读取 Dispatcher 身份、SaaS 只读配置端点及受控 OSS 配置,并保留当前校验和错误报告。历史上游 Schema 未改,删除旧入口不代表真实配置来源已联调。 +- 本地发布与合同脚本:先运行发布检查复现旧校验器因受保护第三方文档的历史 SHA-256 不匹配而中断,未覆盖用户修改、未改写历史 manifest/hash;改由当前合同正反例验证作准入,单独保留上游 v1 文件 SHA-256 的历史来源核对,删除会重新生成/校验过时代次的两个脚本。发布清单只记录当前 local 合同、MQ 拓扑、Proto 及历史来源 manifest 的实际哈希,不再宣称旧 v0.1/v0.3 草案是现行。发布检查先因旧清单格式失败,改完后仍因旧 CLI 错误文案失败;以当前 `dispatcher --mode mixed` 实测确认其在创建 SQLite 前拒绝,再修正检查文本。最终本地发布及打包、防覆盖、mixed/real 资源前拒绝和当前合同/队列检查均通过;仅生成并删除自有临时产物,未部署、未拨号、未处理现存业务数据。 ## 验收台账 diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 86f9c89..212f334 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -55,14 +55,6 @@ def sha256(name): def project_sha256(path): return hashlib.sha256((project / path).read_bytes()).hexdigest() -v01 = json.loads((project / "docs/contracts/local-contract-manifest-v0.1.json").read_text()) -v03 = json.loads((project / "docs/contracts/local-contract-manifest-v0.3.json").read_text()) -topology = json.loads((project / "docs/contracts/mq-topology-v0.1-proposal.json").read_text()) -if (v01["manifest_version"], v03["manifest_version"], topology["contract_version"]) != ( - "local-contract-manifest.v0.1", "local-contract-manifest.v0.3", "project-saas-dispatcher.v0.1" -): - raise SystemExit("unexpected project-local contract or queue topology version") - manifest = { "manifest_version": 1, "scope": "local-development", @@ -76,18 +68,9 @@ manifest = { "go.sum": sha256("go.sum"), }, "contract_attestation": { - "local_business": { - "version": v01["manifest_version"], - "manifest_sha256": project_sha256("docs/contracts/local-contract-manifest-v0.1.json"), - }, - "task_discovery": { - "version": v03["manifest_version"], - "manifest_sha256": project_sha256("docs/contracts/local-contract-manifest-v0.3.json"), - }, - "mq_topology": { - "version": topology["contract_version"], - "manifest_sha256": project_sha256("docs/contracts/mq-topology-v0.1-proposal.json"), - }, + "local_contract_manifest_sha256": project_sha256("contracts/local/manifest.json"), + "local_mq_topology_sha256": project_sha256("contracts/local/mq-topology.json"), + "historical_upstream_manifest_sha256": project_sha256("contracts/upstream/manifest.txt"), "proto_manifest_sha256": project_sha256("proto/manifest.json"), }, "security": { diff --git a/scripts/check-contracts.sh b/scripts/check-contracts.sh index 50714e1..2c3fe3e 100755 --- a/scripts/check-contracts.sh +++ b/scripts/check-contracts.sh @@ -10,5 +10,5 @@ bundle=$(sed -n 's/^active_bundle=//p' "$manifest") cd "$root" grep '^sha256=' "$manifest" | sed 's/^sha256=//' | sha256sum -c - -python3 scripts/validate-local-contracts.py +bash scripts/check-current-contracts.sh go test ./contracts ./internal/contract ./internal/ai diff --git a/scripts/check-release-gates-local.sh b/scripts/check-release-gates-local.sh index cf1a331..69e57c9 100755 --- a/scripts/check-release-gates-local.sh +++ b/scripts/check-release-gates-local.sh @@ -3,9 +3,9 @@ set -euo pipefail ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) mkdir -p -- "$ROOT/dist" -WORK=$(mktemp -d "$ROOT/dist/.f06-local.XXXXXXXX") +WORK=$(mktemp -d "$ROOT/dist/.local-release-check.XXXXXXXX") trap 'rm -rf -- "$WORK"' EXIT -PACKAGE_VERSION="f06-$(basename -- "$WORK" | tr -cd '[:alnum:]')" +PACKAGE_VERSION="local-$(basename -- "$WORK" | tr -cd '[:alnum:]')" PACKAGE_RELEASE="$ROOT/dist/release-$PACKAGE_VERSION" PACKAGE_STAGE="$ROOT/dist/package-$PACKAGE_VERSION" PACKAGE_ARCHIVE="$ROOT/dist/packages/sip-go-agent-$PACKAGE_VERSION-linux-amd64.tar.gz" @@ -60,35 +60,20 @@ assert manifest["security"] == {"credentials_embedded": False, "production_appro assert manifest["binary"]["sha256"] == hashlib.sha256((release / "sip-go-agent").read_bytes()).hexdigest() assert manifest["go_version"].startswith("go version go1.27.1 ") -v01 = json.loads((root / "docs/contracts/local-contract-manifest-v0.1.json").read_text()) -v03 = json.loads((root / "docs/contracts/local-contract-manifest-v0.3.json").read_text()) -topology = json.loads((root / "docs/contracts/mq-topology-v0.1-proposal.json").read_text()) -assert v01["manifest_version"] == "local-contract-manifest.v0.1" -assert v03["manifest_version"] == "local-contract-manifest.v0.3" -assert v03["source"]["path"] == "docs/thirds/v0.3.md" -assert "docs/contracts/task-discovery-v0.3-proposal.schema.json" in {item["path"] for item in v03["artifacts"]} -assert all("task-discovery-v0.2" not in item["path"] for item in v03["artifacts"]) -assert topology["contract_version"] == "project-saas-dispatcher.v0.1" -assert all(topology["queues"][kind]["owner"] == "saas" for kind in ("task", "control", "result")) -assert all(topology["queues"][kind]["queue_name"].endswith(".v3") for kind in ("task", "control", "result")) +topology = json.loads((root / "contracts/local/mq-topology.json").read_text()) +assert topology["ownership"] == "saas" +assert topology["dispatcher"]["control"]["queue"].endswith(".v1") +assert topology["dispatcher"]["task"]["queue"].endswith(".v1") +assert topology["saas"]["result"]["queue"].endswith(".v1") expected_attestation = { - "local_business": { - "version": v01["manifest_version"], - "manifest_sha256": hashlib.sha256((root / "docs/contracts/local-contract-manifest-v0.1.json").read_bytes()).hexdigest(), - }, - "task_discovery": { - "version": v03["manifest_version"], - "manifest_sha256": hashlib.sha256((root / "docs/contracts/local-contract-manifest-v0.3.json").read_bytes()).hexdigest(), - }, - "mq_topology": { - "version": topology["contract_version"], - "manifest_sha256": hashlib.sha256((root / "docs/contracts/mq-topology-v0.1-proposal.json").read_bytes()).hexdigest(), - }, + "local_contract_manifest_sha256": hashlib.sha256((root / "contracts/local/manifest.json").read_bytes()).hexdigest(), + "local_mq_topology_sha256": hashlib.sha256((root / "contracts/local/mq-topology.json").read_bytes()).hexdigest(), + "historical_upstream_manifest_sha256": hashlib.sha256((root / "contracts/upstream/manifest.txt").read_bytes()).hexdigest(), "proto_manifest_sha256": hashlib.sha256((root / "proto/manifest.json").read_bytes()).hexdigest(), } assert manifest["contract_attestation"] == expected_attestation print("local artifact SHA-256:", manifest["binary"]["sha256"]) -print("task discovery source SHA-256:", v03["source"]["sha256"]) +print("current local contract SHA-256:", expected_attestation["local_contract_manifest_sha256"]) print("source dirty:", manifest["source_dirty"]) print("production approved:", manifest["security"]["production_approval"]) PY @@ -99,14 +84,11 @@ if "$WORK/release/sip-go-agent" dispatcher --help | grep -Eq -- '--tenant-key|-- fi for mode in mixed real; do blocked_db="$WORK/blocked-$mode.sqlite" - if SIP_GO_AGENT_MODE="$mode" DISPATCHER_DB="$blocked_db" RABBITMQ_URL='amqp://127.0.0.1:1/' \ - DISPATCHER_GRPC_LISTEN='' DISPATCHER_AGENT_ENDPOINTS_FILE='' \ - GO_SIP_OSS_ACCESS_KEY_ID='local-test-placeholder' GO_SIP_OSS_ACCESS_KEY_SECRET='local-test-placeholder' \ - "$WORK/release/sip-go-agent" dispatcher --config "$ROOT/deploys/config/dispatcher.json.example" > "$WORK/blocked-$mode.log" 2>&1; then + if DISPATCHER_SQLITE_PATH="$blocked_db" "$WORK/release/sip-go-agent" dispatcher --mode "$mode" > "$WORK/blocked-$mode.log" 2>&1; then echo "release accepted unapproved $mode execution" >&2 exit 1 fi - if ! grep -q 'isolated Mock only' "$WORK/blocked-$mode.log" || test -e "$blocked_db"; then + if ! grep -Fq 'Dispatcher accepts only isolated Mock mode' "$WORK/blocked-$mode.log" || test -e "$blocked_db"; then echo "release reached resources before rejecting $mode execution" >&2 exit 1 fi @@ -131,6 +113,10 @@ manifest = json.loads((package / "manifest.json").read_text()) assert manifest["version"] == version assert manifest["scope"] == "local-development" assert manifest["security"]["production_approval"] is False -assert manifest["contract_attestation"]["task_discovery"]["version"] == "local-contract-manifest.v0.3" +assert set(manifest["contract_attestation"]) == { + "local_contract_manifest_sha256", "local_mq_topology_sha256", + "historical_upstream_manifest_sha256", "proto_manifest_sha256", +} +assert all(len(digest) == 64 for digest in manifest["contract_attestation"].values()) PY -echo 'F06 local release/package artifact and contract/queue gates passed; no deployment or dial attempted' +echo 'local release/package artifact and current contract/queue gates passed; no deployment or dial attempted' diff --git a/scripts/generate-local-contract-bundle.sh b/scripts/generate-local-contract-bundle.sh deleted file mode 100644 index 2d45986..0000000 --- a/scripts/generate-local-contract-bundle.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")/.." -mkdir -p contracts/local/v0.1 contracts/local/v0.3 contracts/local/v0.4 -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.3-proposal.schema.json contracts/local/v0.3/ -cp docs/contracts/task-discovery-v0.4-proposal.schema.json docs/contracts/task-control-v0.4-proposal.schema.json docs/contracts/call-execute-v0.4-proposal.schema.json contracts/local/v0.4/ diff --git a/scripts/validate-local-contracts.py b/scripts/validate-local-contracts.py deleted file mode 100644 index 55db40e..0000000 --- a/scripts/validate-local-contracts.py +++ /dev/null @@ -1,569 +0,0 @@ -#!/usr/bin/env python3 -"""Developer-only validation for local F01/F07 contracts, examples, and hashes.""" - -import hashlib -import json -import re -from pathlib import Path - -try: - from jsonschema import Draft202012Validator, FormatChecker, ValidationError - from referencing import Registry, Resource -except ImportError as exc: - raise SystemExit("requires the developer tool jsonschema 4.x; not a Go runtime dependency") from exc - -ROOT = Path(__file__).resolve().parents[1] -DOC = ROOT / "docs/thirds/第三方对接事件与请求消费顺序_v0.1.md" -SCHEMA_PATHS = { - "config-read": ROOT / "docs/contracts/config-read-v0.1.schema.json", - "task-discovery": ROOT / "docs/contracts/task-discovery-v0.1-proposal.schema.json", - "command-next": ROOT / "docs/contracts/command-next-v0.1-proposal.schema.json", - "call-result": ROOT / "docs/contracts/call-result-v0.1-proposal.schema.json", -} -EXAMPLES = ROOT / "docs/contracts/examples" -MANIFEST_PATH = ROOT / "docs/contracts/local-contract-manifest-v0.1.json" -STATUS_FIXTURE = EXAMPLES / "config-read-http-statuses-v0.1.json" -TASK_STATUS_FIXTURE = EXAMPLES / "task-discovery-http-statuses-v0.1.json" - - -def load_json(path): - return json.loads(path.read_text(encoding="utf-8")) - - -registry = Registry() -for path in (ROOT / "contracts/upstream/v1").glob("*.schema.json"): - schema = load_json(path) - if "$id" in schema: - registry = registry.with_resource(schema["$id"], Resource.from_contents(schema)) - - -def schema_versions(node): - found = set() - if isinstance(node, dict): - version = node.get("properties", {}).get("schema_version", {}).get("const") - if isinstance(version, str): - found.add(version) - for value in node.values(): - found.update(schema_versions(value)) - elif isinstance(node, list): - for value in node: - found.update(schema_versions(value)) - return found - - -validators = {} -versions = {} -for name, path in SCHEMA_PATHS.items(): - schema = load_json(path) - Draft202012Validator.check_schema(schema) - schema_version_values = schema_versions(schema) - if not schema_version_values: - raise SystemExit(f"no schema_version const found: {path.relative_to(ROOT)}") - for version in schema_version_values: - if version in versions and versions[version] != name: - raise SystemExit(f"duplicate schema_version {version}") - versions[version] = name - validators[name] = Draft202012Validator( - schema, - registry=registry, - format_checker=FormatChecker(), - ) - -positive_counts = {name: 0 for name in SCHEMA_PATHS} -doc = DOC.read_text(encoding="utf-8") -for index, match in enumerate(re.finditer(r"```json\s*(.*?)\s*```", doc, re.DOTALL), 1): - try: - sample = json.loads(match.group(1)) - except json.JSONDecodeError as exc: - if any(version in match.group(1) for version in versions): - raise SystemExit(f"invalid JSON in local-schema Markdown example {index}: {exc}") from exc - continue - if not isinstance(sample, dict) or sample.get("schema_version") not in versions: - continue - name = versions[sample["schema_version"]] - validators[name].validate(sample) - 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", "-v0.3.json")): - continue - validators["config-read"].validate(load_json(path)) - positive_counts["config-read"] += 1 - -for path in sorted(EXAMPLES.glob("task-discovery-*-v0.1.json")): - if "invalid" in path.name or path == TASK_STATUS_FIXTURE: - continue - validators["task-discovery"].validate(load_json(path)) - positive_counts["task-discovery"] += 1 - -for path in sorted(EXAMPLES.glob("call-result-*.json")): - if "invalid" in path.name: - continue - validators["call-result"].validate(load_json(path)) - positive_counts["call-result"] += 1 - -CONFIG_READ_HTTP_ERROR_CODES = { - 400: {"invalid_request"}, - 401: {"unauthorized"}, - 403: {"dispatcher_not_authorized"}, - 404: {"resource_not_found"}, - 503: {"tenant_quota_unavailable", "service_unavailable"}, -} -TASK_DISCOVERY_HTTP_ERROR_CODES = { - 400: {"invalid_cursor", "invalid_page_token"}, - 401: {"unauthorized"}, - 403: {"dispatcher_not_authorized"}, - 410: {"cursor_expired", "snapshot_expired"}, - 503: {"service_unavailable"}, -} - -def validate_status_fixture(path, schema_name, fixture_version, status_codes): - fixture = load_json(path) - if not isinstance(fixture, dict) or set(fixture) != {"fixture_version", "responses"}: - raise SystemExit(f"invalid HTTP status fixture structure: {path.relative_to(ROOT)}") - if fixture["fixture_version"] != fixture_version or not isinstance(fixture["responses"], list): - raise SystemExit(f"unexpected HTTP status fixture version or responses: {path.relative_to(ROOT)}") - expected = {(status, code) for status, codes in status_codes.items() for code in codes} - actual = set() - for response in fixture["responses"]: - if not isinstance(response, dict) or set(response) != {"status", "body"}: - raise SystemExit(f"invalid HTTP error fixture entry: {response}") - status = response["status"] - body = response["body"] - if type(status) is not int or not isinstance(body, dict): - raise SystemExit(f"invalid HTTP error fixture status/body: {response}") - validators[schema_name].validate(body) - code = body["error"]["code"] - if code not in status_codes.get(status, set()): - raise SystemExit(f"HTTP {status} does not match local {schema_name} error code {code}") - pair = (status, code) - if pair in actual: - raise SystemExit(f"duplicate HTTP error case: {pair}") - actual.add(pair) - if actual != expected: - raise SystemExit(f"HTTP error cases differ from local {schema_name} mapping: missing={expected - actual}, extra={actual - expected}") - return len(actual) - -positive_counts["config-read"] += validate_status_fixture( - STATUS_FIXTURE, "config-read", "config-read-http-statuses.v0.1", CONFIG_READ_HTTP_ERROR_CODES -) -positive_counts["task-discovery"] += validate_status_fixture( - TASK_STATUS_FIXTURE, "task-discovery", "task-discovery-http-statuses.v0.1", TASK_DISCOVERY_HTTP_ERROR_CODES -) -if any(count == 0 for count in positive_counts.values()): - raise SystemExit(f"missing positive contract example: {positive_counts}") - -invalid_prefixes = { - "config-read-invalid-": "config-read", - "task-discovery-invalid-": "task-discovery", - "command-next-invalid-": "command-next", - "call-result-invalid-": "call-result", -} -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")) or (name == "config-read" and path.name.endswith(("-v0.2.json", "-v0.3.json"))): - continue - sample = load_json(path) - try: - validators[name].validate(sample) - except ValidationError: - negative_counts[name] += 1 - else: - raise SystemExit(f"negative fixture unexpectedly valid: {path.relative_to(ROOT)}") - -if any(count == 0 for count in negative_counts.values()): - raise SystemExit(f"missing negative fixture per local schema: {negative_counts}") - -def validate_manifest(): - if not MANIFEST_PATH.is_file(): - raise SystemExit(f"required local contract manifest is missing: {MANIFEST_PATH.relative_to(ROOT)}") - manifest = load_json(MANIFEST_PATH) - expected_source = DOC.relative_to(ROOT).as_posix() - if manifest.get("manifest_version") != "local-contract-manifest.v0.1": - raise SystemExit("unexpected local contract manifest version") - if manifest.get("hash_algorithm") != "SHA-256": - raise SystemExit("local contract manifest must use SHA-256") - source = manifest.get("source") - if not isinstance(source, dict) or source.get("path") != expected_source: - raise SystemExit(f"local contract manifest source must be {expected_source}") - artifacts = manifest.get("artifacts") - if not isinstance(artifacts, list): - raise SystemExit("local contract manifest artifacts must be a list") - required_artifacts = {path.relative_to(ROOT).as_posix() for path in SCHEMA_PATHS.values()} - required_artifacts.update({ - "docs/contracts/config-read-fields-v0.1-proposal.md", - "docs/contracts/mq-topology-v0.1-proposal.json", - }) - required_artifacts.update( - 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", "-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)] - if len(artifact_paths) != len(artifacts) or len(set(artifact_paths)) != len(artifact_paths): - raise SystemExit("local contract manifest has malformed or duplicate artifacts") - if set(artifact_paths) != required_artifacts: - raise SystemExit(f"local contract manifest artifact set mismatch: missing={required_artifacts - set(artifact_paths)}, extra={set(artifact_paths) - required_artifacts}") - entries = [source, *artifacts] - for entry in entries: - relative = Path(entry.get("path", "")) - expected_hash = entry.get("sha256") - if relative.is_absolute() or ".." in relative.parts or not re.fullmatch(r"[0-9a-f]{64}", str(expected_hash)): - raise SystemExit(f"invalid path or SHA-256 in local contract manifest: {entry}") - path = ROOT / relative - if not path.is_file(): - raise SystemExit(f"manifest file is missing: {relative}") - actual_hash = hashlib.sha256(path.read_bytes()).hexdigest() - if actual_hash != expected_hash: - raise SystemExit(f"SHA-256 mismatch for {relative}: expected {expected_hash}, got {actual_hash}") - return len(entries) - -manifest_file_count = validate_manifest() -print(f"Local schemas: {len(validators)} valid; positive examples: {positive_counts}; negative fixtures rejected: {negative_counts}; SHA-256 manifest files verified: {manifest_file_count}") - -# Validate the separate v0.2 task-discovery proposal without changing the -# v0.1 runtime baseline or the historical v0.1 manifest. -proposal_path = ROOT / "docs/contracts/task-discovery-v0.2-proposal.schema.json" -proposal_schema = load_json(proposal_path) -Draft202012Validator.check_schema(proposal_schema) -if schema_versions(proposal_schema) != {"task-discovery.v0.2-proposal"}: - raise SystemExit("unexpected task-discovery v0.2 schema version") -validators["task-discovery-v0.2"] = Draft202012Validator( - proposal_schema, registry=registry, format_checker=FormatChecker() -) -proposal = validators["task-discovery-v0.2"] -proposal_examples = sorted(EXAMPLES.glob("task-discovery-*-v0.2.json")) -proposal_status = EXAMPLES / "task-discovery-http-statuses-v0.2.json" -proposal_positive = 0 -proposal_negative = 0 -for path in proposal_examples: - if path == proposal_status: - continue - sample = load_json(path) - if "invalid" in path.name: - try: - proposal.validate(sample) - except ValidationError: - proposal_negative += 1 - else: - raise SystemExit(f"negative v0.2 fixture unexpectedly valid: {path.relative_to(ROOT)}") - else: - proposal.validate(sample) - proposal_positive += 1 -proposal_positive += validate_status_fixture( - proposal_status, "task-discovery-v0.2", "task-discovery-http-statuses.v0.2", - {400: {"invalid_cursor"}, 401: {"unauthorized"}, - 403: {"dispatcher_not_authorized"}, 410: {"cursor_expired"}, - 503: {"service_unavailable"}}, -) -if proposal_positive < 8 or proposal_negative < 2: - raise SystemExit("missing v0.2 positive/negative contract cases") -for example in ("snapshot", "changes"): - payload = load_json(EXAMPLES / f"task-discovery-{example}-v0.2.json") - field = "tasks" if example == "snapshot" else "changes" - payload[field] = [payload[field][0]] * 256 - proposal.validate(payload) - payload[field].append(payload[field][0]) - try: - proposal.validate(payload) - except ValidationError: - proposal_negative += 1 - else: - 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", "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: - validators[proposal_versions[sample["schema_version"]]].validate(sample) - proposal_positive += 1 - -proposal_manifest = load_json(ROOT / "docs/contracts/local-contract-manifest-v0.2.json") -if proposal_manifest.get("manifest_version") != "local-contract-manifest.v0.2" or proposal_manifest.get("hash_algorithm") != "SHA-256": - raise SystemExit("invalid v0.2 manifest version or hash algorithm") -proposal_artifacts = { - proposal_path.relative_to(ROOT).as_posix(), - *(path.relative_to(ROOT).as_posix() for path in proposal_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", -} -source = proposal_manifest.get("source", {}) -artifacts = proposal_manifest.get("artifacts", []) -if source.get("path") != proposal_doc.relative_to(ROOT).as_posix() or not isinstance(artifacts, list): - raise SystemExit("v0.2 manifest source/artifacts mismatch") -paths = [entry.get("path") for entry in artifacts if isinstance(entry, dict)] -if len(paths) != len(artifacts) or len(paths) != len(set(paths)) or set(paths) != proposal_artifacts: - raise SystemExit("v0.2 manifest artifact set mismatch") -for entry in [source, *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.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 (historical): positive={proposal_positive}, negative={proposal_negative}, manifest files={1 + len(artifacts)}") - -# The SIP-only v0.2 response replaces the old static-artifact SIP payload; -# the other config-read resources remain on v0.1. -sip_schema_path = ROOT / "docs/contracts/config-read-v0.2.schema.json" -if (ROOT / "contracts/local/v0.2/config-read-v0.2.schema.json").read_bytes() != sip_schema_path.read_bytes(): - raise SystemExit("embedded SIP config-read v0.2 schema differs from project source") -sip_schema = load_json(sip_schema_path) -Draft202012Validator.check_schema(sip_schema) -sip_validator = validators["config-read-sip-v0.2"] -sip_positive = EXAMPLES / "config-read-sip-v0.2.json" -sip_negative = EXAMPLES / "config-read-sip-invalid-artifact-v0.2.json" -sip_validator.validate(load_json(sip_positive)) -try: - sip_validator.validate(load_json(sip_negative)) -except ValidationError: - pass -else: - raise SystemExit("old artifact payload unexpectedly accepted by SIP v0.2") -sip_manifest = load_json(ROOT / "docs/contracts/local-contract-manifest-v0.5.json") -expected_sip_files = { - "docs/thirds/v0.2.md", "docs/contracts/config-read-v0.2.schema.json", - "docs/contracts/examples/config-read-sip-v0.2.json", - "docs/contracts/examples/config-read-sip-invalid-artifact-v0.2.json", -} -if (sip_manifest.get("manifest_version") != "local-contract-manifest.v0.5" - or sip_manifest.get("hash_algorithm") != "SHA-256" - or sip_manifest.get("source", {}).get("path") != "docs/contracts/config-read-fields-v0.2-proposal.md"): - raise SystemExit("invalid SIP v0.2 manifest header") -sip_entries = [sip_manifest["source"], *sip_manifest.get("artifacts", [])] -if {entry.get("path") for entry in sip_entries[1:]} != expected_sip_files or len(sip_entries) != 5: - raise SystemExit("SIP v0.2 manifest artifact set mismatch") -for entry in sip_entries: - path = ROOT / entry["path"] - if hashlib.sha256(path.read_bytes()).hexdigest() != entry.get("sha256"): - 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" -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)}") - -# v0.4 is a new project-local contract, not a mutation of v0.3 or upstream v1. -v4_doc = ROOT / "docs/thirds/v0.4.md" -v4_schema_paths = { - name: ROOT / f"docs/contracts/{name}-v0.4-proposal.schema.json" - for name in ("task-discovery", "task-control", "call-execute") -} -v4_registry = registry -v4_examples = {name: sorted(EXAMPLES.glob(f"{name}-*-v0.4.json")) for name in v4_schema_paths} -v4_positive = v4_negative = 0 -for name, schema_path in v4_schema_paths.items(): - schema = load_json(schema_path) - Draft202012Validator.check_schema(schema) - if schema_versions(schema) != {f"{name}.v0.4-proposal"}: - raise SystemExit(f"unexpected v0.4 schema version: {schema_path}") - embedded = ROOT / "contracts/local/v0.4" / schema_path.name - if embedded.read_bytes() != schema_path.read_bytes(): - raise SystemExit(f"embedded v0.4 schema differs from project source: {schema_path.name}") - validator = Draft202012Validator(schema, registry=v4_registry, format_checker=FormatChecker()) - positive = negative = 0 - for path in v4_examples[name]: - sample = load_json(path) - if "invalid" in path.name: - try: - validator.validate(sample) - except ValidationError: - negative += 1 - else: - raise SystemExit(f"invalid v0.4 fixture passed: {path.relative_to(ROOT)}") - else: - validator.validate(sample) - positive += 1 - if positive < 3 or negative < 1: - raise SystemExit(f"missing v0.4 positive/negative fixtures for {schema_path.name}") - v4_positive += positive - v4_negative += negative -v4_manifest = load_json(ROOT / "docs/contracts/local-contract-manifest-v0.4.json") -if v4_manifest.get("manifest_version") != "local-contract-manifest.v0.4" or v4_manifest.get("hash_algorithm") != "SHA-256": - raise SystemExit("invalid v0.4 manifest version or hash algorithm") -v4_source = v4_manifest.get("source", {}) -v4_artifacts = v4_manifest.get("artifacts", []) -required_v4 = { - *(p.relative_to(ROOT).as_posix() for p in v4_schema_paths.values()), - *(p.relative_to(ROOT).as_posix() for examples in v4_examples.values() for p in examples), - "contracts/upstream/v1/mq.schema.json", -} -v4_paths = [entry.get("path") for entry in v4_artifacts if isinstance(entry, dict)] -if v4_source.get("path") != v4_doc.relative_to(ROOT).as_posix() or len(v4_paths) != len(v4_artifacts) or len(v4_paths) != len(set(v4_paths)) or set(v4_paths) != required_v4: - raise SystemExit("v0.4 manifest source/artifacts mismatch") -for entry in [v4_source, *v4_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.4 manifest entry: {entry}") - if not (ROOT / relative).is_file() or hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() != digest: - raise SystemExit(f"v0.4 manifest SHA-256 mismatch: {relative}") -print(f"Task discovery/control/execute v0.4 proposal: positive={v4_positive}, negative={v4_negative}, manifest files={1 + len(v4_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. -mock_doc = ROOT / "docs/contracts/local-mock-recording-failure-v0.1.md" -mock_schema_path = ROOT / "docs/contracts/local-mock-recording-failure-v0.1.schema.json" -mock_schema = load_json(mock_schema_path) -embedded_mock_schema = ROOT / "contracts/local/v0.1/local-mock-recording-failure-v0.1.schema.json" -if embedded_mock_schema.read_bytes() != mock_schema_path.read_bytes(): - raise SystemExit("embedded Mock failure schema differs from its hashed project source") -Draft202012Validator.check_schema(mock_schema) -if schema_versions(mock_schema) != {"local-mock-recording-failure.v0.1"}: - raise SystemExit("unexpected local Mock recording-failure schema version") -mock_validator = Draft202012Validator(mock_schema, registry=registry, format_checker=FormatChecker()) -mock_examples = sorted(EXAMPLES.glob("local-mock-recording-failure-*-v0.1.json")) -mock_positive = mock_negative = 0 -for path in mock_examples: - sample = load_json(path) - if "invalid" in path.name: - try: - mock_validator.validate(sample) - except ValidationError: - mock_negative += 1 - else: - raise SystemExit(f"invalid Mock failure fixture passed: {path.relative_to(ROOT)}") - else: - mock_validator.validate(sample) - mock_positive += 1 -if mock_positive < 2 or mock_negative < 2: - raise SystemExit("missing positive/negative Mock recording-failure examples") -mock_manifest = load_json(ROOT / "docs/contracts/local-mock-recording-failure-manifest-v0.1.json") -if mock_manifest.get("manifest_version") != "local-mock-recording-failure-manifest.v0.1" or mock_manifest.get("hash_algorithm") != "SHA-256": - raise SystemExit("invalid Mock failure manifest version or hash algorithm") -mock_source = mock_manifest.get("source", {}) -mock_artifacts = mock_manifest.get("artifacts", []) -mock_paths = [entry.get("path") for entry in mock_artifacts if isinstance(entry, dict)] -required_mock_paths = {mock_schema_path.relative_to(ROOT).as_posix(), *(path.relative_to(ROOT).as_posix() for path in mock_examples)} -if mock_source.get("path") != mock_doc.relative_to(ROOT).as_posix() or len(mock_paths) != len(mock_artifacts) or len(mock_paths) != len(set(mock_paths)) or set(mock_paths) != required_mock_paths: - raise SystemExit("Mock failure manifest source/artifact set mismatch") -for entry in [mock_source, *mock_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 Mock failure manifest entry: {entry}") - if not (ROOT / relative).is_file() or hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() != digest: - raise SystemExit(f"Mock failure manifest SHA-256 mismatch: {relative}") -print(f"Mock recording failure v0.1: positive={mock_positive}, negative={mock_negative}, manifest files={1 + len(mock_artifacts)}")