proto: freeze the wire contract at 1.0.0

Schemas for the whole v1 surface: 38 methods, 9 events, 25 named types and the
JSON-RPC envelope, with x-privileged / x-transports / x-deadlineMs / x-errors
annotations that both generators emit as data rather than prose.

Four generators over one IR (contracts/codegen/schema_ir.py), so the C++ structs,
the TypeScript types and the OpenRPC document cannot disagree about what the
contract says:

  gen_cpp.py             -> core/generated/velox_proto.{hpp,cpp}
  gen_ts.py              -> extension/src/shared/protocol/
  gen_openrpc.py         -> contracts/openrpc.json
  gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp

Inbound parsing never throws: parse<T>() returns std::expected<T, ParseError> and
nlohmann's throwing ADL from_json is deliberately not emitted. Schema constraints
(minimum, maxLength, pattern, ...) become real runtime checks in both languages —
the daemon does not trust the extension and the extension does not trust the
daemon.

59 golden fixtures: a success case per method, 12 error cases, 9 events. Replayed
by tests/conformance/ against both the generated C++ and a live server over both
transports. tools/mockd serves the same fixtures with unhappy-path flags so the
GUI and EXT lanes never wait for veloxd.

run.sh also proves capture.offer fails open: with a daemon answering slower than
750 ms the client gives up and lets Firefox take the download.

core/generated/ is libveloxproto, a separate target from libveloxcore, which
still never sees JSON — see docs/adr/0009.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
This commit is contained in:
2026-09-09 19:55:54 +04:00
co-authored by Claude Opus 5
parent a40585f419
commit 53421d6cb8
171 changed files with 29275 additions and 51 deletions
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""Static conformance: the contract, the fixtures and the generated code agree.
This is the cheap half of the suite and the half that catches the most. It runs without a
daemon, without Node and without a compiler, so it is the first thing CI does on every
lane's PR.
Checks
1. Every schema file parses and every $ref resolves.
2. Every method in contracts/README.md's surface has a schema, and vice versa.
3. Every method has at least one success fixture.
4. Every fixture's params and result validate against that method's schema.
5. Every error fixture uses a code the ErrorCode type defines, and one the method
documents in x-errors (or a universal code).
6. Every event has a fixture, and every event fixture validates.
7. SettingKey and Settings.properties name exactly the same keys.
8. The committed generated code is up to date with the schemas.
Run: python3 tests/conformance/check_contract.py
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "contracts" / "codegen"))
import jsonschema # noqa: E402
from referencing import Registry, Resource # noqa: E402
from referencing.jsonschema import DRAFT202012 # noqa: E402
from schema_ir import ID_PREFIX, Loader, load # noqa: E402
SCHEMA = REPO / "contracts" / "schema"
FIXTURES = REPO / "contracts" / "fixtures"
# Values that cannot be pinned in a golden file. The runners treat them as "any value of
# the right shape"; here they are swapped for a concrete one so the schema can be applied.
PLACEHOLDERS = {
"$uuid": "e6f0a1b2-3c4d-4e5f-8a9b-0c1d2e3f4a5b",
"$isoDate": "2026-09-09T10:14:52Z",
"$any": "placeholder",
# Bound by the runner to a task it creates, so a fixture never depends on a task id
# that happens to exist in a seeded mock.
"$taskId": "3f7a2b1c-5d6e-4f80-9a1b-2c3d4e5f6071",
"$taskId2": "8c1d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
# Long enough to satisfy a token's minLength: an opaque credential-shaped string.
"$opaque": "cGxhY2Vob2xkZXItdG9rZW4tNjQtYnl0ZXMtb2YtZW50cm9weS1nb2VzLWhlcmU",
}
# Codes any method may return regardless of its x-errors list.
UNIVERSAL = {-32700, -32600, -32601, -32602, -32603, -32001, -32002, -32003}
failures: list[str] = []
def fail(msg: str) -> None:
failures.append(msg)
def substitute(node: object) -> object:
if isinstance(node, str):
return PLACEHOLDERS.get(node, node)
if isinstance(node, list):
return [substitute(n) for n in node]
if isinstance(node, dict):
return {k: substitute(v) for k, v in node.items()}
return node
def build_registry(loader: Loader) -> Registry:
resources = [(sid, Resource(contents=doc, specification=DRAFT202012))
for sid, doc in loader.by_id.items()]
return Registry().with_resources(resources)
def validate(registry: Registry, ref: str, instance: object, where: str) -> None:
validator = jsonschema.Draft202012Validator({"$ref": ref}, registry=registry)
errors = sorted(validator.iter_errors(instance), key=lambda e: list(e.absolute_path))
for e in errors[:3]:
path = "/".join(str(p) for p in e.absolute_path) or "<root>"
fail(f"{where}: {path}: {e.message}")
def iter_fixtures() -> list[tuple[Path, dict]]:
out = []
for p in sorted(FIXTURES.rglob("*.json")):
with p.open() as fh:
out.append((p, json.load(fh)))
return out
def main() -> int:
loader = Loader()
registry = build_registry(loader)
contract = load()
method_ids = {doc["title"]: sid for sid, doc in loader.by_id.items() if "/methods/" in sid}
event_ids = {doc["title"]: sid for sid, doc in loader.by_id.items() if "/events/" in sid}
# 2. the schema surface matches the documented surface, both directions
readme = (REPO / "contracts" / "README.md").read_text()
for name in method_ids:
# The README writes runs of related methods as `download.start` | `.pause` | ...
short = "." + name.split(".", 1)[1]
if name not in readme and short not in readme:
fail(f"method {name} has a schema but is not in contracts/README.md")
namespaces = {n.split(".", 1)[0] for n in method_ids}
for token in set(re.findall(r"\b([a-z]+\.[a-zA-Z][A-Za-z]*)\b", readme)):
ns = token.split(".", 1)[0]
if ns in namespaces and token not in method_ids:
fail(f"contracts/README.md documents {token}, which has no schema")
# 7. SettingKey and Settings agree
keys = set(loader.by_id[ID_PREFIX + "types/SettingKey.schema.json"]["enum"])
props = set(loader.by_id[ID_PREFIX + "types/Settings.schema.json"]["properties"])
for k in sorted(keys - props):
fail(f"SettingKey lists {k} but Settings.schema.json has no such property")
for k in sorted(props - keys):
fail(f"Settings.schema.json has property {k} but SettingKey does not list it")
# 3-6. fixtures
covered_methods: set[str] = set()
covered_events: set[str] = set()
for path, doc in iter_fixtures():
rel = path.relative_to(REPO)
is_event = "notification" in doc
if is_event:
frame = doc["notification"]
name = frame.get("method")
if name not in event_ids:
fail(f"{rel}: unknown event {name!r}")
continue
covered_events.add(name)
validate(registry, event_ids[name] + "#/properties/params",
substitute(frame.get("params")), f"{rel} params")
continue
request = doc.get("request")
if not isinstance(request, dict):
fail(f"{rel}: no request object")
continue
name = request.get("method")
if name not in method_ids:
# method-not-found.json deliberately names a method that does not exist.
if doc.get("response", {}).get("error", {}).get("code") == -32601:
continue
fail(f"{rel}: unknown method {name!r}")
continue
sid = method_ids[name]
expected_code = doc.get("response", {}).get("error", {}).get("code") if doc.get("response") else None
if expected_code == -32602:
# This fixture exists precisely because its params are invalid. Assert that
# they really do fail the schema, or it is testing nothing.
v = jsonschema.Draft202012Validator({"$ref": sid + "#/properties/params"}, registry=registry)
if not list(v.iter_errors(substitute(request.get("params", {})))):
fail(f"{rel}: expects -32602 but its params are schema-valid")
else:
validate(registry, sid + "#/properties/params", substitute(request.get("params", {})),
f"{rel} request.params")
response = doc.get("response")
if response is None:
if doc.get("kind") != "timeout":
fail(f"{rel}: null response without \"kind\": \"timeout\"")
continue
if "result" in response:
covered_methods.add(name)
validate(registry, sid + "#/properties/result", substitute(response["result"]),
f"{rel} response.result")
elif "error" in response:
validate(registry, ID_PREFIX + "envelope.schema.json#/$defs/Error",
substitute(response["error"]), f"{rel} response.error")
code = response["error"]["code"]
declared = set(loader.by_id[sid].get("x-errors", []))
if code not in declared | UNIVERSAL:
fail(f"{rel}: error {code} is not in {name}'s x-errors {sorted(declared)}")
else:
fail(f"{rel}: response has neither result nor error")
# ids must correlate
if response.get("id") != request.get("id"):
fail(f"{rel}: response id does not match request id")
for name in sorted(method_ids):
if name not in covered_methods:
fail(f"method {name} has no success fixture — a method with no fixture is not done")
for name in sorted(event_ids):
if name not in covered_events:
fail(f"event {name} has no fixture")
# 8. generated code is current
for gen, out in [("gen_cpp.py", ["core/generated/velox_proto.hpp", "core/generated/velox_proto.cpp"]),
("gen_ts.py", ["extension/src/shared/protocol/types.ts",
"extension/src/shared/protocol/methods.ts",
"extension/src/shared/protocol/events.ts",
"extension/src/shared/protocol/validate.ts",
"extension/src/shared/protocol/index.ts"]),
("gen_openrpc.py", ["contracts/openrpc.json"])]:
before = {f: (REPO / f).read_bytes() for f in out if (REPO / f).exists()}
subprocess.run([sys.executable, str(REPO / "contracts" / "codegen" / gen)],
check=True, capture_output=True)
for f in out:
if (REPO / f).read_bytes() != before.get(f):
fail(f"{f} is stale: re-run contracts/codegen/{gen} and commit the result")
print(f"contract v{contract.version}: {len(method_ids)} methods, {len(event_ids)} events, "
f"{len(list(iter_fixtures()))} fixtures")
if failures:
print(f"\n{len(failures)} problem(s):\n")
for f in failures:
print(" FAIL", f)
return 1
print("static conformance OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())