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
+454
View File
@@ -0,0 +1,454 @@
"""Load contracts/schema/ and lower it into a small IR the generators emit from.
There is deliberately only one of these. gen_cpp.py, gen_ts.py and gen_openrpc.py all
consume the same IR, so the C++ structs, the TypeScript types and the human-readable
API document cannot disagree about what the contract says.
The IR covers exactly the JSON Schema subset the contract is allowed to use. Anything
outside it raises SchemaError at generation time rather than producing subtly wrong code:
a contract that cannot be generated from is a contract bug, and it should stop the build.
Supported subset
----------------
type: object / string / integer / number / boolean / array, and ["X", "null"]
object with `properties` -> struct
object with `additionalProperties: <schema>` -> map<string, T>
string with `enum` -> enum
integer with `enum` + x-enum -> named integer enum
array with `items` -> vector<T>
$ref to a types/*.schema.json -> named type
oneOf: [{$ref}, {type: null}] -> nullable named type
{} -> opaque JSON
Not supported, on purpose: allOf, anyOf, general oneOf, patternProperties, tuple items,
recursive types. If the contract needs one of these, extend this file in the same PR.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
SCHEMA_ROOT = Path(__file__).resolve().parent.parent / "schema"
ID_PREFIX = "https://velox.dev/schema/"
class SchemaError(Exception):
"""A schema the generators refuse to guess about."""
# --------------------------------------------------------------------------- IR
# Value constraints the generators enforce at runtime. The wire is never trusted, so a
# `maximum` in the schema has to be a check in the generated code, not just documentation.
CONSTRAINT_KEYS = ("minimum", "maximum", "minLength", "maxLength", "pattern",
"minItems", "maxItems")
@dataclass(frozen=True)
class TypeRef:
"""A reference to a type from a field. `kind` drives every emitter's switch."""
kind: str # named | string | integer | number | boolean | array | map | json
name: str | None = None # kind == "named"
inner: "TypeRef | None" = None # kind in ("array", "map")
constraints: tuple[tuple[str, object], ...] = ()
@property
def limits(self) -> dict[str, object]:
return dict(self.constraints)
@dataclass
class Field:
name: str
type: TypeRef
required: bool
nullable: bool
doc: str = ""
@property
def optional(self) -> bool:
"""Whether the emitted field needs an optional/undefined-able representation."""
return (not self.required) or self.nullable
@dataclass
class EnumValue:
name: str # identifier form, e.g. RetryWait
wire: object # the value on the wire: "retry_wait" or -32010
doc: str = ""
@dataclass
class TypeDef:
name: str
kind: str # struct | string_enum | int_enum | map_alias
doc: str = ""
fields: list[Field] = field(default_factory=list)
values: list[EnumValue] = field(default_factory=list)
alias: TypeRef | None = None
source: str = "" # relative path, for the "do not edit" banner
@dataclass
class Method:
name: str # download.addBatch
doc: str
params: TypeRef
result: TypeRef
privileged: bool
transports: list[str]
deadline_ms: int
errors: list[int] = field(default_factory=list)
ws_restrictions: list[str] = field(default_factory=list)
@dataclass
class Event:
name: str # event.task.progress
doc: str
params: TypeRef
max_rate_hz: float | None = None
@dataclass
class Contract:
version: str
types: list[TypeDef]
methods: list[Method]
events: list[Event]
error_codes: list[EnumValue]
# ------------------------------------------------------------------ name helpers
def pascal(text: str) -> str:
"""download.addBatch -> DownloadAddBatch ; retry_wait -> RetryWait."""
parts = re.split(r"[.\-_ ]+", text)
out = []
for part in parts:
if not part:
continue
out.append(part[0].upper() + part[1:])
return "".join(out)
def enum_ident(value: str) -> str:
ident = pascal(value)
if not ident:
raise SchemaError(f"cannot derive an identifier from enum value {value!r}")
if ident[0].isdigit():
ident = "V" + ident
return ident
# ---------------------------------------------------------------------- loading
class Loader:
def __init__(self, root: Path = SCHEMA_ROOT):
self.root = root
self.by_id: dict[str, dict] = {}
self.source_of: dict[str, str] = {}
for path in sorted(root.rglob("*.schema.json")):
with path.open() as fh:
doc = json.load(fh)
sid = doc.get("$id")
if not sid:
raise SchemaError(f"{path} has no $id")
if sid in self.by_id:
raise SchemaError(f"duplicate $id {sid} in {path}")
self.by_id[sid] = doc
self.source_of[sid] = str(path.relative_to(root.parent))
self.types: dict[str, TypeDef] = {}
self._order: list[str] = []
# -- ref handling -------------------------------------------------------
def _resolve_ref(self, ref: str) -> dict:
if ref.startswith("#"):
raise SchemaError(f"local $ref {ref} is not supported outside envelope.schema.json")
if ref not in self.by_id:
raise SchemaError(f"unknown $ref {ref}")
return self.by_id[ref]
@staticmethod
def _split_nullable(node: dict) -> tuple[dict, bool]:
"""Normalise the two ways the contract spells 'or null'."""
if "oneOf" in node:
branches = node["oneOf"]
non_null = [b for b in branches if b.get("type") != "null"]
nulls = [b for b in branches if b.get("type") == "null"]
if len(branches) != 2 or len(non_null) != 1 or len(nulls) != 1:
raise SchemaError(
"oneOf is only supported as [<schema>, {type: null}]; got "
+ json.dumps(branches)[:200]
)
merged = dict(non_null[0])
for key in ("description",):
if key in node and key not in merged:
merged[key] = node[key]
return merged, True
t = node.get("type")
if isinstance(t, list):
non_null = [x for x in t if x != "null"]
if len(non_null) != 1:
raise SchemaError(f"union type {t} is only supported as [X, 'null']")
node = dict(node)
node["type"] = non_null[0]
# An enum listing null alongside its values means the same thing.
if "enum" in node:
node["enum"] = [v for v in node["enum"] if v is not None]
return node, True
if "enum" in node and None in node["enum"]:
node = dict(node)
node["enum"] = [v for v in node["enum"] if v is not None]
return node, True
return node, False
# -- lowering -----------------------------------------------------------
def type_ref(self, node: dict, hint: str) -> tuple[TypeRef, bool]:
"""Lower a schema node to a TypeRef. `hint` names any struct we must synthesise."""
node, nullable = self._split_nullable(node)
limits = tuple((k, node[k]) for k in CONSTRAINT_KEYS if k in node)
if "$ref" in node:
target = self._resolve_ref(node["$ref"])
return TypeRef("named", name=self.named_type(node["$ref"], target)), nullable
if not node or node.keys() <= {"description"}:
return TypeRef("json"), True
t = node.get("type")
if t == "string":
if "enum" in node:
return TypeRef("named", name=self._synth_string_enum(hint, node)), nullable
return TypeRef("string", constraints=limits), nullable
if t == "integer":
return TypeRef("integer", constraints=limits), nullable
if t == "number":
return TypeRef("number", constraints=limits), nullable
if t == "boolean":
return TypeRef("boolean", constraints=limits), nullable
if t == "array":
items = node.get("items")
if items is None:
raise SchemaError(f"array without items at {hint}")
inner, _ = self.type_ref(items, hint + "Item")
return TypeRef("array", inner=inner, constraints=limits), nullable
if t == "object":
if "properties" in node:
return TypeRef("named", name=self._synth_struct(hint, node)), nullable
ap = node.get("additionalProperties")
if isinstance(ap, dict):
inner, _ = self.type_ref(ap, hint + "Value")
return TypeRef("map", inner=inner), nullable
return TypeRef("json"), nullable
if "const" in node:
return TypeRef("string", constraints=limits), nullable
raise SchemaError(f"unsupported schema node at {hint}: {json.dumps(node)[:200]}")
def _register(self, td: TypeDef) -> str:
existing = self.types.get(td.name)
if existing is not None:
if existing.kind != td.kind:
raise SchemaError(f"type name collision on {td.name}")
return td.name
self.types[td.name] = td
self._order.append(td.name)
return td.name
def _synth_string_enum(self, name: str, node: dict) -> str:
values = [
EnumValue(name=enum_ident(v), wire=v)
for v in node["enum"]
if v is not None
]
return self._register(TypeDef(name=name, kind="string_enum",
doc=node.get("description", ""), values=values))
def _synth_struct(self, name: str, node: dict) -> str:
if node.get("additionalProperties", False) is not False:
raise SchemaError(
f"{name}: object schemas must set additionalProperties:false — the daemon is "
"not allowed to trust unknown fields on the wire"
)
required = set(node.get("required", []))
fields: list[Field] = []
for prop, sub in node.get("properties", {}).items():
ref, nullable = self.type_ref(sub, name + pascal(prop))
fields.append(Field(name=prop, type=ref, required=prop in required,
nullable=nullable, doc=sub.get("description", "")))
return self._register(TypeDef(name=name, kind="struct",
doc=node.get("description", ""), fields=fields))
def named_type(self, sid: str, doc: dict) -> str:
"""Lower a top-level types/*.schema.json into a TypeDef and return its name."""
name = doc.get("title")
if not name:
raise SchemaError(f"{sid} has no title")
if name in self.types:
return name
source = self.source_of.get(sid, "")
node, _ = self._split_nullable(doc)
t = node.get("type")
if t == "string" and "enum" in node:
# Placeholder first: enums cannot recurse, but registering early keeps the
# ordering stable and mirrors the struct path below.
values = [EnumValue(name=enum_ident(v), wire=v) for v in node["enum"] if v is not None]
td = TypeDef(name, "string_enum", node.get("description", ""), values=values, source=source)
return self._register(td)
if t == "integer" and "x-enum" in node:
values = [EnumValue(name=e["name"], wire=e["value"], doc=e.get("doc", ""))
for e in node["x-enum"]]
td = TypeDef(name, "int_enum", node.get("description", ""), values=values, source=source)
return self._register(td)
if t == "object" and "properties" not in node:
ap = node.get("additionalProperties")
if not isinstance(ap, dict):
raise SchemaError(f"{sid}: object type with neither properties nor a typed additionalProperties")
inner, _ = self.type_ref(ap, name + "Value")
td = TypeDef(name, "map_alias", node.get("description", ""),
alias=TypeRef("map", inner=inner), source=source)
return self._register(td)
if t == "object":
# Reserve the name before descending so a nested synth cannot steal it.
self.types[name] = TypeDef(name, "struct", node.get("description", ""), source=source)
self._order.append(name)
required = set(node.get("required", []))
if node.get("additionalProperties", False) is not False:
raise SchemaError(f"{sid}: object schemas must set additionalProperties:false")
fields = []
for prop, sub in node.get("properties", {}).items():
ref, nullable = self.type_ref(sub, name + pascal(prop))
fields.append(Field(prop, ref, prop in required, nullable, sub.get("description", "")))
self.types[name].fields = fields
return name
raise SchemaError(f"{sid}: unsupported top-level type {t!r}")
# ---------------------------------------------------------------------- driver
def load() -> Contract:
loader = Loader()
root = loader.root.parent
version = (root / "VERSION").read_text().strip()
# Named types first, so their names win over any synthesised ones.
for sid in sorted(loader.by_id):
if "/types/" in sid:
loader.named_type(sid, loader.by_id[sid])
methods: list[Method] = []
for sid in sorted(loader.by_id):
if "/methods/" not in sid:
continue
doc = loader.by_id[sid]
name = doc["title"]
base = pascal(name)
props = doc.get("properties", {})
for half in ("params", "result"):
if half not in props:
raise SchemaError(f"{sid}: method schema must define both params and result")
params, _ = loader.type_ref(props["params"], base + "Params")
result, _ = loader.type_ref(props["result"], base + "Result")
transports = doc.get("x-transports")
if not transports:
raise SchemaError(f"{sid}: x-transports is required")
if "x-privileged" not in doc:
raise SchemaError(f"{sid}: x-privileged is required")
methods.append(Method(
name=name, doc=doc.get("description", ""), params=params, result=result,
privileged=bool(doc["x-privileged"]), transports=list(transports),
deadline_ms=int(doc.get("x-deadlineMs", 5000)),
errors=list(doc.get("x-errors", [])),
ws_restrictions=list(doc.get("x-wsRestrictions", [])),
))
events: list[Event] = []
for sid in sorted(loader.by_id):
if "/events/" not in sid:
continue
doc = loader.by_id[sid]
name = doc["title"]
ident = pascal(name[len("event."):] if name.startswith("event.") else name) + "Event"
params, _ = loader.type_ref(doc["properties"]["params"], ident)
events.append(Event(name=name, doc=doc.get("description", ""), params=params,
max_rate_hz=doc.get("x-maxRateHz")))
error_codes = loader.types["ErrorCode"].values
ordered = [loader.types[n] for n in loader._order]
return Contract(version=version, types=ordered, methods=methods, events=events,
error_codes=error_codes)
def dependencies(td: TypeDef) -> set[str]:
"""Named types `td` mentions directly."""
out: set[str] = set()
def walk(ref: TypeRef | None) -> None:
if ref is None:
return
if ref.kind == "named" and ref.name:
out.add(ref.name)
walk(ref.inner)
for f in td.fields:
walk(f.type)
walk(td.alias)
return out
def topo_sorted(types: list[TypeDef]) -> list[TypeDef]:
"""Definition order for languages that need a type declared before it is used.
The contract forbids recursive types, so a cycle here means a schema bug and is
raised rather than broken arbitrarily.
"""
by_name = {t.name: t for t in types}
state: dict[str, int] = {}
order: list[TypeDef] = []
def visit(name: str, trail: list[str]) -> None:
mark = state.get(name, 0)
if mark == 2:
return
if mark == 1:
raise SchemaError("recursive type: " + " -> ".join(trail + [name]))
state[name] = 1
for dep in sorted(dependencies(by_name[name])):
if dep in by_name:
visit(dep, trail + [name])
state[name] = 2
order.append(by_name[name])
for t in types:
visit(t.name, [])
return order
if __name__ == "__main__":
c = load()
print(f"contract {c.version}: {len(c.types)} types, {len(c.methods)} methods, {len(c.events)} events")
for t in c.types:
detail = f"{len(t.fields)} fields" if t.kind == "struct" else f"{len(t.values)} values"
print(f" {t.kind:12} {t.name:34} {detail}")