feat: complete MQ-only dispatcher and OSS upload flow

This commit is contained in:
2026-09-22 21:09:06 +08:00
parent 704652bd0d
commit a2fcc4aabb
265 changed files with 18689 additions and 1469 deletions
+18 -2
View File
@@ -12,10 +12,26 @@ go build -trimpath -buildvcs=false -o dist/sip-go-agent ./cmd/sip-go-agent
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cp deploys/config/dispatcher.json.example "$tmp/dispatcher.json"
chmod 600 "$tmp/dispatcher.json"
# The strict Dispatcher file requires explicitly referenced credentials even in
# mock mode. These are local-test values and never reach an OSS endpoint.
export GO_SIP_OSS_ACCESS_KEY_ID=local-mock-access-key
export GO_SIP_OSS_ACCESS_KEY_SECRET=local-mock-access-secret
./dist/sip-go-agent agent --mode mock --spool "$tmp/spool" >/dev/null
./dist/sip-go-agent dispatcher --mode mock --db "$tmp/dispatcher.db" >/dev/null
rabbit_url=${GO_SIP_LOCAL_MQ_URL:-${RABBITMQ_URL:-}}
if [ -z "$rabbit_url" ]; then
echo "GO_SIP_LOCAL_MQ_URL or RABBITMQ_URL is required for Dispatcher acceptance" >&2
exit 1
fi
export GO_SIP_LOCAL_MQ_URL=${GO_SIP_LOCAL_MQ_URL:-$rabbit_url}
export GO_SIP_LOCAL_QUERY_MQ_URL=${GO_SIP_LOCAL_QUERY_MQ_URL:-$rabbit_url}
export GO_SIP_LOCAL_UPLOAD_MQ_URL=${GO_SIP_LOCAL_UPLOAD_MQ_URL:-$rabbit_url}
export RABBITMQ_URL=${RABBITMQ_URL:-$rabbit_url}
./dist/sip-go-agent dispatcher --mode mock --config "$tmp/dispatcher.json" --db "$tmp/dispatcher.db" --rabbit-url "$rabbit_url" --tenant-key local-acceptance-tenant --once >/dev/null
./scripts/mq-only-acceptance-local.sh >/dev/null
if ./dist/sip-go-agent dispatcher --mode real --db "$tmp/real.db" >/dev/null 2>&1; then
if env -u GO_SIP_LOCAL_MQ_URL -u GO_SIP_LOCAL_QUERY_MQ_URL -u GO_SIP_LOCAL_UPLOAD_MQ_URL -u RABBITMQ_URL ./dist/sip-go-agent dispatcher --mode real --config "$tmp/dispatcher.json" --db "$tmp/real.db" --tenant-key local-acceptance-tenant --once >/dev/null 2>&1; then
echo "real mode unexpectedly started without broker credentials" >&2
exit 1
fi
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
profile=${COVER_PROFILE:-/tmp/go-sip-coverage.out}
business_profile=${COVER_BUSINESS_PROFILE:-/tmp/go-sip-coverage-business.out}
GOMAXPROCS=${GOMAXPROCS:-2} go test -p 1 -coverpkg=./... -coverprofile="$profile" ./...
awk 'NR == 1 || index($0, "/gen/") == 0' "$profile" > "$business_profile"
go tool cover -func="$business_profile"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$ROOT"
# A missing broker must fail the command, never turn these tests into skips.
BASE_URL=${GO_SIP_LOCAL_MQ_URL:-${RABBITMQ_URL:-amqp://guest:guest@127.0.0.1:33252/}}
export GO_SIP_LOCAL_MQ_URL="$BASE_URL"
export GO_SIP_LOCAL_QUERY_MQ_URL="${GO_SIP_LOCAL_QUERY_MQ_URL:-$BASE_URL}"
export GO_SIP_LOCAL_UPLOAD_MQ_URL="${GO_SIP_LOCAL_UPLOAD_MQ_URL:-$BASE_URL}"
export RABBITMQ_URL="${RABBITMQ_URL:-$BASE_URL}"
LOG_FILE=${MQ_ONLY_ACCEPTANCE_LOG:-/tmp/go-sip-mq-only-acceptance.log}
: > "$LOG_FILE"
run() {
printf '$'
printf ' %q' "$@"
printf '\n' | tee -a "$LOG_FILE"
"$@" 2>&1 | tee -a "$LOG_FILE"
}
run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v ./internal/dispatcher \
-run 'TestLocalMQ(AIConfigurationAuthorizationRoundTrip|ActiveControlReplyRecovery|RequestRoundTrip)$' -count=1
run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v ./internal/rpc \
-run '^TestLocalUploadNoticeSurvivesUnroutableAndRestart$' -count=1
run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v -tags integration ./internal/mq \
-run 'TestV2LocalBrokerIdentityIsolationAndReliableRouting|TestLocalRabbitMQConfirmAckAndDeadLetter|TestV2LocalBrokerDisconnectStopsPublisher' -count=1
run env GOMAXPROCS=2 GOTOOLCHAIN=local go test -race -p 1 -v ./internal/store \
-run 'TestMQControlRejectsLateRevisionWithoutRegressingTask|TestMQReplayOnlyRequeuesOriginalBusinessFacts' -count=1
if grep -Eq -- '--- SKIP:|\(no tests to run\)' "$LOG_FILE"; then
echo "targeted MQ acceptance unexpectedly skipped a test; see $LOG_FILE" >&2
exit 1
fi
echo "targeted MQ acceptance passed without skipped tests; log=$LOG_FILE"
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""Reproduce the project-local v2 draft from approved rules and pinned v1 inputs.
The v1 input is never changed. Upload delivery ends at the designated durable
MQ queue: no upload-session request, verified response or SaaS OSS ID protocol.
"""
import copy
import hashlib
import json
from pathlib import Path
from typing import Any
import yaml
ROOT = Path(__file__).resolve().parent.parent
OLD = ROOT / "contracts/upstream/2026-09-19-p1-v1"
NEW = ROOT / "contracts/upstream/2026-09-21-p1-v2"
BASE = "https://go-sip.local/contracts/2026-09-21-p1-v2/"
DID = "c046b893-8628-4589-ae50-619d049248a6"
AT = "2026-09-21T00:00:00Z"
SCHEMA = "https://json-schema.org/draft/2020-12/schema"
WRITTEN = set()
def emit_text(name, text):
path = NEW / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
WRITTEN.add(name)
def emit(name, value):
emit_text(name, json.dumps(value, ensure_ascii=False, indent=2) + "\n")
def load_json(path):
try:
return json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(f"Cannot load pinned source schema {path}") from exc
def ref(name):
return {"$ref": "#/$defs/" + name}
def obj(properties, required=None):
return {"type": "object", "additionalProperties": False,
"required": list(properties) if required is None else required,
"properties": properties}
def rewrite_refs(value, prefix):
if isinstance(value, list):
return [rewrite_refs(v, prefix) for v in value]
if not isinstance(value, dict):
return value
result = {k: rewrite_refs(v, prefix) for k, v in value.items()}
r = result.get("$ref", "")
if r.startswith("#/components/schemas/"):
result["$ref"] = "#/$defs/" + prefix + r.split("/")[-1]
return result
def uploaded_event_schema(value: Any) -> Any:
if isinstance(value, list):
return [uploaded_event_schema(v) for v in value]
if isinstance(value, dict):
return {k: uploaded_event_schema(v) for k, v in value.items()}
if value == "recording.ready":
return "recording.uploaded"
if value == "#/$defs/recording_ready":
return "#/$defs/recording_uploaded"
return value
# Preserve unrelated business schemas; the old package and its IDs stay intact.
for path in sorted(OLD.glob("*.schema.json")):
schema = load_json(path)
schema["$id"] = BASE + path.name
emit(path.name, schema)
mq = load_json(OLD / "mq.schema.json")
events = load_json(OLD / "event-payloads.schema.json")
defs = mq["$defs"]
dispatcher_id = {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}
tenant_key = {"type": "string", "minLength": 1, "maxLength": 196,
"not": {"pattern": "(^|\\.)[*#](\\.|$)"},
"$comment": "Runtime also enforces 196 UTF-8 bytes and each AMQP resource's 255-byte budget."}
command_types = ["call.execute", "task.control", "call.replay", "command.replay"]
# Replace ready, rather than falsely reporting SaaS processing or an OSS ID.
uploaded = events["$defs"].pop("recording_ready")
uploaded["properties"].pop("oss_id")
uploaded["required"].remove("oss_id")
for name, schema in {"upload_id": ref("id"), "bucket": {"type": "string", "minLength": 1, "maxLength": 63}, "object_key": {"type": "string", "minLength": 1, "maxLength": 1024}}.items():
uploaded["properties"][name] = schema
uploaded["required"].append(name)
events["$defs"]["recording_uploaded"] = uploaded
events = uploaded_event_schema(events)
if not isinstance(events, dict):
raise TypeError("event schema transformation must produce an object")
events["$id"] = BASE + "event-payloads.schema.json"
events["properties"]["schema_version"] = {"const": "2.0"}
events["properties"]["dispatcher_id"] = dispatcher_id
events["properties"]["tenant_key"] = tenant_key
events["required"].append("dispatcher_id")
events["$defs"]["command_result"]["properties"]["command_type"]["enum"] = command_types
emit("event-payloads.schema.json", events)
defs["dispatcherId"] = dispatcher_id
defs["tenantKey"] = tenant_key
for filename, prefix in [("executor.openapi.yaml", "executor_"), ("ai-config.openapi.yaml", "ai_")]:
document = yaml.safe_load((OLD / filename).read_text())
for name, schema in document["components"]["schemas"].items():
defs[prefix + name] = rewrite_refs(schema, prefix)
def event_collection(kinds):
return {"type": "array", "items": {
"allOf": [{"$ref": "event-payloads.schema.json"},
{"properties": {"event_type": {"enum": kinds}}}]}}
# Approved query gap closure: typed event collections and outbox counts.
defs["executor_Command"]["additionalProperties"] = False
call = defs["executor_Call"]
call["additionalProperties"] = False
call["properties"]["attempts"] = event_collection(["call.status"])
call["properties"]["transcript"] = obj({"events": event_collection(["transcript.updated", "transcript.failed"])})
call["properties"]["recordings"] = event_collection(["recording.uploaded", "recording.failed"])
call["properties"]["delivery"] = obj({k: {"type": "integer", "minimum": 0} for k in ["pending", "retry", "dispatching", "published"]})
command = copy.deepcopy(defs.pop("executeCommand"))
command["properties"].update({"schema_version": {"const": "2.0"}, "dispatcher_id": ref("dispatcherId"), "tenant_key": ref("tenantKey"), "command_type": {"enum": command_types}, "payload": {"type": "object"}})
command["required"].append("dispatcher_id")
control = copy.deepcopy(defs["executor_ControlRequest"])
# The existing MQ envelope is the sole command_id authority, not a second body ID.
control["properties"].pop("command_id")
control["required"].remove("command_id")
control["properties"]["task_id"] = ref("id")
control["required"].append("task_id")
defs["taskControl"] = control
for name, target in [("callReplay", "call_id"), ("commandReplay", "source_command_id")]:
value = copy.deepcopy(defs["executor_ReplayRequest"])
value["properties"].pop("command_id")
value["required"].remove("command_id")
value["properties"][target] = ref("id")
value["required"].append(target)
defs[name] = value
command["allOf"] = [{"if": {"properties": {"command_type": {"const": kind}}}, "then": {"properties": {"payload": ref(payload)}}} for kind, payload in zip(command_types, ["executePayload", "taskControl", "callReplay", "commandReplay"], strict=True)]
defs["command"] = command
defs["event"] = {"$ref": "event-payloads.schema.json"}
# Flatten allOf so the source receipt's closed object permits the known config.
version = copy.deepcopy(defs["ai_AgentVersionReceipt"])
version["properties"]["config"] = {"$ref": "ai-config.schema.json"}
version["required"].append("config")
defs["ai_AgentVersion"] = version
defs["aiConfigResult"] = obj({"snapshot": ref("ai_AgentVersion"), "authorization": {"$ref": "ai-authorization.schema.json"}})
request_payloads = {"command.query": obj({"command_id": ref("id")}), "call.query": obj({"call_id": ref("id")}), "ai.config.request": obj({"agent_version_id": ref("id")})}
response_payloads = {"command.query.result": ref("executor_Command"), "call.query.result": ref("executor_Call"), "ai.config.result": ref("aiConfigResult")}
common = {"schema_version": {"const": "2.0"}, "message_type": {"type": "string"}, "message_id": ref("id"), "dispatcher_id": ref("dispatcherId"), "tenant_id": ref("id"), "tenant_key": ref("tenantKey"), "trace_id": ref("id"), "issued_at": {"type": "string", "format": "date-time"}}
request = obj({**common, "not_after": {"type": "string", "format": "date-time"}, "payload": {"type": "object"}})
request["properties"]["message_type"] = {"enum": list(request_payloads)}
request["allOf"] = [{"if": {"properties": {"message_type": {"const": kind}}}, "then": {"properties": {"payload": payload}}} for kind, payload in request_payloads.items()]
defs["request"] = request
reasons = ["invalid_request", "not_found", "conflict", "expired", "not_authorized", "unavailable", "unsupported"]
response = obj({**common, "correlation_id": ref("id"), "status": {"enum": ["ok", "pending", "rejected"]}, "reason_code": {"type": "string"}, "payload": {"type": "object"}})
response["properties"]["message_type"] = {"enum": list(response_payloads)}
response["allOf"] = [
{"if": {"properties": {"status": {"const": "pending"}}}, "then": {"properties": {"reason_code": {"const": "waiting"}, "payload": obj({})}}},
{"if": {"properties": {"status": {"const": "rejected"}}}, "then": {"properties": {"reason_code": {"enum": reasons}, "payload": obj({"detail": {"type": "string", "maxLength": 1024}, "retryable": {"type": "boolean"}})}}},
]
for kind, payload in response_payloads.items():
response["allOf"].append({"if": {"properties": {"status": {"const": "ok"}, "message_type": {"const": kind}}}, "then": {"properties": {"reason_code": {"const": "ok"}, "payload": payload}}})
defs["response"] = response
mq["$id"] = BASE + "mq.schema.json"
mq["oneOf"] = [ref(k) for k in ["command", "event", "request", "response"]]
def local_refs(value):
if isinstance(value, list):
return set().union(*(local_refs(v) for v in value))
if not isinstance(value, dict):
return set()
found = set()
if value.get("$ref", "").startswith("#/$defs/"):
found.add(value["$ref"].split("/")[2])
for k, v in value.items():
if k != "$defs":
found.update(local_refs(v))
return found
# Do not publish unused HTTP-specific definitions as a second supported contract.
pending, reachable = local_refs(mq), set()
while pending:
name = pending.pop()
if name not in reachable:
reachable.add(name)
pending.update(local_refs(defs[name]) - reachable)
mq["$defs"] = {name: defs[name] for name in sorted(reachable)}
emit("mq.schema.json", mq)
config = obj({"schema_version": {"const": "1.0"}, "dispatcher_id": dispatcher_id, "oss": obj({k: {"type": "string", "minLength": 1} for k in ["endpoint", "region", "bucket", "object_prefix", "access_key_id_env", "access_key_secret_env"]})})
config.update({"$schema": SCHEMA, "$id": BASE + "dispatcher-config.schema.json"})
config["properties"]["oss"]["properties"]["endpoint"].update({"format": "uri", "pattern": "^https?://"})
for key in ["access_key_id_env", "access_key_secret_env"]:
config["properties"]["oss"]["properties"][key]["pattern"] = "^[A-Za-z_][A-Za-z0-9_]*$"
emit("dispatcher-config.schema.json", config)
emit("mq-topology.json", {"version": "2.0", "exchanges": {name: {"type": "topic", "durable": True} for name in ["agent-call.dispatchers.v2", "agent-call.saas.v2", "agent-call.dead-letter.v2"]}, "inbox_queue": "agent-call.d.<dispatcher_id>.t.<tenant_key>.v2", "dead_letter_queue": "agent-call.d.<dispatcher_id>.t.<tenant_key>.dlq.v2", "inbound_key": "d.<dispatcher_id>.t.<tenant_key>.in", "outbound_key": "d.<dispatcher_id>.t.<tenant_key>.out", "saas_queue": "agent-call.saas.events.v2", "owner_queue": "agent-call.d.<dispatcher_id>.owner.v2", "owner_exclusive": True, "business_queues_durable": True, "message_persistent": True, "publish_mandatory": True, "publisher_confirms": True, "tenant_key_max_utf8_bytes": 196, "message_max_bytes": 262144, "service_request_deadline_seconds": 30, "upload_token_seconds": 900})
cases = []
def fixture(name, value, valid=True, schema="mq.schema.json"):
filename = "examples/" + name + ".json"
emit(filename, value)
cases.append({"file": filename, "schema": schema, "valid": valid})
base = {"schema_version": "2.0", "dispatcher_id": DID, "tenant_id": "tenant-a", "tenant_key": "tenant-a", "trace_id": "trace-v2", "issued_at": AT}
execute = {**base, "command_id": "command-a", "command_type": "call.execute", "not_after": "2026-09-21T00:00:30Z", "payload": {"execution_id": "execution-a", "task_id": "task-a", "task_item_id": "item-a", "task_revision": 1, "callee": "15003164745", "route_policy_id": "route-a", "caller_profile_id": "caller-a", "agent_version_id": "version-a", "variables": {}, "ring_timeout_ms": 1000, "max_call_duration_ms": 10000}}
fixture("call-execute", execute)
for kind, payload in [("task.control", {"task_id": "task-a", "action": "pause", "expected_task_revision": 1, "active_call_policy": "drain", "reason": "local-test"}), ("call.replay", {"call_id": "call-a", "reason": "local-test"}), ("command.replay", {"source_command_id": "command-a", "reason": "local-test"})]:
fixture(kind.replace(".", "-"), {**execute, "command_id": kind + "-a", "command_type": kind, "payload": payload})
for kind, payload in [("command.query", {"command_id": "command-a"}), ("call.query", {"call_id": "call-a"}), ("ai.config.request", {"agent_version_id": "version-a"})]:
fixture(kind.replace(".", "-"), {**base, "message_id": kind + "-a", "message_type": kind, "not_after": "2026-09-21T00:00:30Z", "payload": payload})
reply = {**base, "message_id": "query-reply-a", "message_type": "command.query.result", "correlation_id": "command.query-a", "status": "ok", "reason_code": "ok"}
fixture("query-pending", {**reply, "status": "pending", "reason_code": "waiting", "payload": {}})
fixture("query-rejected", {**reply, "status": "rejected", "reason_code": "not_found", "payload": {"detail": "command not found", "retryable": False}})
fixture("dispatcher-config", {"schema_version": "1.0", "dispatcher_id": DID, "oss": {"endpoint": "https://oss.example.invalid", "region": "example-region", "bucket": "example-bucket", "object_prefix": "recordings", "access_key_id_env": "DISPATCHER_OSS_ACCESS_KEY_ID", "access_key_secret_env": "DISPATCHER_OSS_ACCESS_KEY_SECRET"}}, schema="dispatcher-config.schema.json")
fixture("invalid-v1", {**execute, "schema_version": "1.0"}, False)
fixture("invalid-wildcard", {**execute, "tenant_key": "tenant.#"}, False)
fixture("invalid-dispatcher", {**execute, "dispatcher_id": "dispatcher-a"}, False)
# Every event and successful response has a fixture, not just a compilable schema.
event_examples = {}
for path in sorted((OLD / "examples").glob("event-*.json")):
event = load_json(path)
event.update({"schema_version": "2.0", "dispatcher_id": DID, "tenant_id": "tenant-a", "tenant_key": "tenant-a"})
if "call_id" in event["payload"]:
event["payload"]["call_id"] = "call-a"
if event["aggregate_type"] == "call":
event["aggregate_id"] = "call-a"
name = path.stem
if event["event_type"] == "recording.ready":
event["event_type"] = "recording.uploaded"
event["payload"].pop("oss_id")
event["payload"].update({"upload_id": "upload-a", "bucket": "example-bucket", "object_key": "recordings/recording-a.wav"})
name = "event-recording-uploaded"
event["event_id"] = event["event_type"] + "-event-a"
event_examples[event["event_type"]] = event
fixture(name, event, schema="event-payloads.schema.json")
command_result = {"command_id": "command-a", "command_type": "call.execute", "tenant_id": "tenant-a", "tenant_key": "tenant-a", "status": "accepted", "aggregate_version": 1}
fixture("command-query-result", {**reply, "payload": command_result})
fixture("invalid-correlation", {k: v for k, v in {**reply, "payload": command_result}.items() if k != "correlation_id"}, False)
call_result = {"call_id": "call-a", "execution_id": event_examples["call.status"]["payload"]["execution_id"], "call_state": event_examples["call.status"]["payload"]["call_state"], "call_version": 1, "attempts": [event_examples["call.status"]], "transcript": {"events": [event_examples["transcript.updated"], event_examples["transcript.failed"]]}, "recordings": [event_examples["recording.uploaded"], event_examples["recording.failed"]], "delivery": {"pending": 1, "retry": 0, "dispatching": 0, "published": 0}, "snapshot_at": AT}
fixture("call-query-result", {**reply, "message_id": "call-query-reply-a", "message_type": "call.query.result", "correlation_id": "call.query-a", "payload": call_result})
invalid_query = copy.deepcopy(call_result)
invalid_query["delivery"]["raw"] = {}
fixture("invalid-call-query-extra", {**reply, "message_type": "call.query.result", "payload": invalid_query}, False)
ai_config = load_json(OLD / "examples/agent-version-full-explicit.json")
ai_config["agent_version_id"] = "version-a"
config_digest = hashlib.sha256(json.dumps(ai_config, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
snapshot = {"tenant_id": "tenant-a", "agent_version_id": "version-a", "status": "published", "immutable": True, "content_sha256": config_digest, "config": ai_config}
authorization = load_json(OLD / "examples/ai-authorization.json")
authorization.update({"tenant_id": "tenant-a", "tenant_key": "tenant-a", "agent_version_id": "version-a", "config_sha256": config_digest, "mode": ai_config["mode"]})
fixture("ai-config-result", {**reply, "message_id": "ai-config-reply-a", "message_type": "ai.config.result", "correlation_id": "ai.config.request-a", "payload": {"snapshot": snapshot, "authorization": authorization}})
emit("fixtures.json", cases)
emit_text("README.md", "# Project-local MQ-only v2\n\nApproved design: docs/contracts/mq-only-v2-freeze-proposal.md, amended by the enqueue-only upload goal.\nThis is a local project contract, not external SaaS acceptance. Old v1 files are untouched.\nPublisher: scripts/publish-mq-v2.py. Validator: go test ./contracts -run TestV2.\n\nUpload notification: recording.uploaded, no recording.ready, upload session or verified reply. Completion means persistent delivery to the designated durable MQ queue, mandatory routing and publisher confirm; not SaaS consumption.\nPayload: call_id, recording_id, upload_id, bucket, object_key, format, channels, sample_rate_hz, duration_ms, size_bytes, checksum_sha256. No TOKEN, credentials, signed URL or SaaS OSS ID.\n\nControl/replay command_id belongs solely to the envelope. Query nested structures reuse typed events and delivery counts. AI receipt/config composition is flattened without widening fields.\nSchema limits characters; runtime also checks UTF-8 bytes and aggregate response size. Oversized snapshots fail explicitly, never silently truncate.\n")
actual = {str(p.relative_to(NEW)) for p in NEW.rglob("*") if p.is_file() and p.name != "manifest.json"}
if extra := actual - WRITTEN:
raise RuntimeError(f"Unpublished/stale files remain in draft bundle; inspect explicitly: {sorted(extra)}")
files = {name: hashlib.sha256((NEW / name).read_bytes()).hexdigest() for name in sorted(WRITTEN)}
emit("manifest.json", {"version": "2026-09-21-p1-v2", "source": "project-approved-mq-only-v2", "derived_from": "2026-09-19-p1-v1", "source_files": {str(p.relative_to(OLD)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(OLD.rglob("*")) if p.is_file()}, "files": files})
print(f"Published {len(files)} pinned project-local draft files in {NEW.relative_to(ROOT)}")
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Publish the approved JCS digest revision without modifying older bundles."""
import hashlib
import importlib
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import jsonschema
from referencing import Registry, Resource
ROOT = Path(__file__).resolve().parent.parent
OLD_VERSION = "2026-09-21-p1-v2"
VERSION = "2026-09-21-p1-v3"
OLD = ROOT / "contracts/upstream" / OLD_VERSION
NEW = OLD.parent / VERSION
MODULE = "github.com/cyberphone/json-canonicalization"
REVISION = "v0.0.0-20241213102144-19d51d7fe467"
info = json.loads(subprocess.check_output(["go", "mod", "download", "-json", MODULE + "@" + REVISION], cwd=ROOT))
if info.get("Error") or info.get("Version") != REVISION:
raise RuntimeError("pinned JCS implementation unavailable")
sys.path.insert(0, str(Path(info["Dir"]) / "python3/src"))
canonicalize = importlib.import_module("org.webpki.json.Canonicalize").canonicalize
def encoded(value):
return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode()
def digest(value):
return hashlib.sha256(canonicalize(value)).hexdigest()
manifest = json.loads((OLD / "manifest.json").read_text())
files = {}
for name, expected in manifest["files"].items():
raw = (OLD / name).read_bytes()
if hashlib.sha256(raw).hexdigest() != expected:
raise RuntimeError("source bundle hash mismatch: " + name)
files[name] = raw.replace(OLD_VERSION.encode(), VERSION.encode())
result = json.loads(files["examples/ai-config-result.json"])
result["payload"]["snapshot"]["content_sha256"] = digest(result["payload"]["snapshot"]["config"])
files["examples/ai-config-result.json"] = encoded(result)
oldest = ROOT / "contracts/upstream/2026-09-19-p1-v1/examples"
for name in ("agent-version.json", "agent-version-asr-only.json", "agent-version-full-explicit.json"):
files["examples/" + name] = (oldest / name).read_bytes()
authorization = json.loads((oldest / "ai-authorization.json").read_text())
authorization["config_sha256"] = digest(json.loads(files["examples/agent-version-asr-only.json"]))
files["examples/ai-authorization.json"] = encoded(authorization)
vectors = [
{"name": "numbers", "input": '{"b":1.0,"a":[-0.0,1e30,4.50,2e-3]}'},
{"name": "utf16-key-order", "input": '{"\\ue000":1,"\\ud800\\udc00":2}'},
{"name": "strings", "input": '{"s":"\\u20ac/\\n","flag":false,"zero":0}'},
]
for vector in vectors:
value = json.loads(vector["input"])
vector["canonical"] = canonicalize(value).decode()
vector["sha256"] = digest(value)
files["jcs-golden.json"] = encoded(vectors)
files["ai-digest.json"] = encoded({
"scope": "AI snapshot.config and authorization config_sha256 only",
"canonicalization": "RFC 8785 (JCS)", "digest": "SHA-256", "encoding": "lowercase hex",
"implementation": MODULE, "revision": REVISION,
"external_acceptance": False,
"invariants": ["No Unicode normalization", "Reject invalid UTF-8 and duplicate object keys",
"Preserve explicit zero/false versus absent fields", "No old-digest fallback"],
})
files["README.md"] = (f"# {VERSION}\n\nProject-local JCS/SHA-256 revision approved by the user. "
f"Derived from {OLD_VERSION}; older packages are unchanged. Wire schema_version remains 2.0.\n\n"
"AI digests use RFC 8785 canonical UTF-8 bytes followed by SHA-256, lowercase hexadecimal. "
"This does not change file checksums, upload facts, command-body identity, or other hashes. "
"Numbers follow JCS IEEE-754 rules; no Unicode normalization is performed. "
"Explicit zero/false remain distinct from absent fields. No legacy digest fallback.\n\n"
"Local agreement and generated fixtures are not external SaaS acceptance.\n").encode()
# Validate positive and negative fixtures against an entirely offline registry
# before the directory becomes an immutable published bundle.
schemas = {name: json.loads(raw) for name, raw in files.items() if name.endswith(".schema.json")}
registry = Registry().with_resources((value["$id"], Resource.from_contents(value)) for value in schemas.values())
for fixture in json.loads(files["fixtures.json"]):
validator = jsonschema.Draft202012Validator(schemas[fixture["schema"]], registry=registry,
format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER)
valid = validator.is_valid(json.loads(files[fixture["file"]]))
if valid != fixture["valid"]:
raise RuntimeError("fixture validation mismatch: " + fixture["file"])
for name in ("agent-version.json", "agent-version-asr-only.json", "agent-version-full-explicit.json"):
jsonschema.Draft202012Validator(schemas["ai-config.schema.json"], registry=registry).validate(json.loads(files["examples/" + name]))
jsonschema.Draft202012Validator(schemas["ai-authorization.schema.json"], registry=registry).validate(authorization)
files["manifest.json"] = encoded({
"version": VERSION, "source": "project-approved-jcs-sha256", "derived_from": OLD_VERSION,
"source_manifest_sha256": hashlib.sha256((OLD / "manifest.json").read_bytes()).hexdigest(),
"files": {name: hashlib.sha256(raw).hexdigest() for name, raw in sorted(files.items())},
})
if NEW.exists():
existing = {str(p.relative_to(NEW)): p.read_bytes() for p in NEW.rglob("*") if p.is_file()}
if existing != files:
raise RuntimeError("immutable destination differs; publish a new version instead")
else:
with tempfile.TemporaryDirectory(prefix=".jcs-publish-", dir=OLD.parent) as temporary:
stage = Path(temporary) / VERSION
for name, raw in files.items():
target = stage / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(raw)
os.rename(stage, NEW)
print(VERSION, hashlib.sha256(files["manifest.json"]).hexdigest())