279 lines
18 KiB
Python
279 lines
18 KiB
Python
#!/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)}")
|