diff --git a/gen/go/protocol/protocol.go b/gen/go/protocol/protocol.go index 936d30b..784f887 100644 --- a/gen/go/protocol/protocol.go +++ b/gen/go/protocol/protocol.go @@ -14,7 +14,7 @@ import ( "time" ) -const SchemaSHA256 = "8c1ed430127cf5774a919f14ff4770b2509d322c8518bf30c412e1e89eeffced" +const SchemaSHA256 = "b9e72838b2ae02b3a47e7e2dfa03620b8580f2c98668e6eaf551ea8fb642d297" const ProtocolVersion = "1.0.0" const CurrentWireVersion = "2" const NMinus1WireVersion = "1" diff --git a/gen/manifest.json b/gen/manifest.json index cf13779..a8bb5a9 100644 --- a/gen/manifest.json +++ b/gen/manifest.json @@ -14,5 +14,5 @@ }, "generator_sha256": "c5756780f59a82d8f56762e52f58e22a4d6aca6648157f45e5bad6559b9deeec", "protocol_version": "1.0.0", - "schema_sha256": "8c1ed430127cf5774a919f14ff4770b2509d322c8518bf30c412e1e89eeffced" + "schema_sha256": "b9e72838b2ae02b3a47e7e2dfa03620b8580f2c98668e6eaf551ea8fb642d297" } diff --git a/gen/rust/protocol.rs b/gen/rust/protocol.rs index 589a881..923c7f7 100644 --- a/gen/rust/protocol.rs +++ b/gen/rust/protocol.rs @@ -1,6 +1,6 @@ // Code generated by tools/generate.py; DO NOT EDIT. #![allow(non_snake_case)] -pub const SCHEMA_SHA256: &str = "8c1ed430127cf5774a919f14ff4770b2509d322c8518bf30c412e1e89eeffced"; +pub const SCHEMA_SHA256: &str = "b9e72838b2ae02b3a47e7e2dfa03620b8580f2c98668e6eaf551ea8fb642d297"; pub const CURRENT_WIRE_VERSION: &str = "2"; pub const N_MINUS_1_WIRE_VERSION: &str = "1"; pub const N_MINUS_2_WIRE_VERSION: &str = "0"; diff --git a/gen/swift/Protocol.swift b/gen/swift/Protocol.swift index 485fb33..31bae34 100644 --- a/gen/swift/Protocol.swift +++ b/gen/swift/Protocol.swift @@ -1,7 +1,7 @@ // Code generated by tools/generate.py; DO NOT EDIT. import Foundation public typealias JSONObject = [String: String] -public let schemaSHA256 = "8c1ed430127cf5774a919f14ff4770b2509d322c8518bf30c412e1e89eeffced" +public let schemaSHA256 = "b9e72838b2ae02b3a47e7e2dfa03620b8580f2c98668e6eaf551ea8fb642d297" public let currentWireVersion = "2" public let nMinus1WireVersion = "1" public let nMinus2WireVersion = "0" diff --git a/openapi/control-v1.yaml b/openapi/control-v1.yaml index a4973b3..4d9eae9 100644 --- a/openapi/control-v1.yaml +++ b/openapi/control-v1.yaml @@ -440,7 +440,7 @@ paths: post: tags: [gateway] operationId: acknowledgeGatewayQualityWork - description: The authenticated gateway mTLS certificate identity MUST match `gateway_id`; Server authority MUST exactly match the session, gateway, reconnect sequence, operation, revision, and monotonic lease generation tuple. Stale lease generations MUST be rejected. Maximum JSON body: 16384 bytes. + description: The authenticated gateway mTLS certificate identity MUST match `gateway_id`; Server authority MUST exactly match the session, gateway, reconnect sequence, operation, revision, and monotonic lease generation tuple. Stale lease generations MUST be rejected. `applied` requires `current_applied_revision == revision`; `proven_prior` requires `current_applied_revision < revision`; `unknown` forbids `current_applied_revision` and makes no applied-revision assertion. Maximum JSON body: 16384 bytes. security: [{gatewayMutualTLS: []}] requestBody: {required: true, description: 'Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayQualityAck}}}} responses: diff --git a/schemas/control-v1.schema.json b/schemas/control-v1.schema.json index 0545c01..f497904 100644 --- a/schemas/control-v1.schema.json +++ b/schemas/control-v1.schema.json @@ -740,7 +740,13 @@ "GatewayQualityAck": { "type": "object", "additionalProperties": false, "x-max-bytes": 16384, + "description": "Outcome invariants: applied requires current_applied_revision equal to revision; proven_prior requires current_applied_revision strictly less than revision; unknown forbids current_applied_revision and makes no applied-revision assertion.", "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "revision", "lease_generation", "outcome"], + "oneOf": [ + {"properties": {"outcome": {"const": "applied"}}, "required": ["current_applied_revision"]}, + {"properties": {"outcome": {"const": "proven_prior"}}, "required": ["current_applied_revision"]}, + {"properties": {"outcome": {"const": "unknown"}}, "not": {"required": ["current_applied_revision"]}} + ], "properties": { "version": {"type": "string", "const": "1"}, "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, "gateway_id": {"type": "string", "minLength": 1, "maxLength": 128}, "reconnect_sequence": {"type": "integer", "minimum": 0}, diff --git a/tools/validate.py b/tools/validate.py index 86e6b91..325e0a2 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -12,6 +12,17 @@ import sys ROOT = pathlib.Path(__file__).resolve().parents[1] +def matches_outcome_branch(instance: dict[str, object], branch: dict[str, object]) -> bool: + required = branch.get("required", []) + if not all(field in instance for field in required): + return False + outcome = branch.get("properties", {}).get("outcome", {}).get("const") + if instance.get("outcome") != outcome: + return False + forbidden = branch.get("not", {}).get("required", []) + return not all(field in instance for field in forbidden) if forbidden else True + + def main() -> int: schema_path = ROOT / "schemas/control-v1.schema.json" schema = json.loads(schema_path.read_text(encoding="utf-8")) @@ -59,8 +70,32 @@ def main() -> int: assert quality_request["properties"]["lease_generation"]["minimum"] == 1 assert defs["GatewayStopWorkRequest"]["required"] == ["version", "session_id", "gateway_id", "reconnect_sequence", "acquisition"] assert defs["GatewayQualityWork"]["required"][-3:] == ["lease_generation", "lease_expires_at", "selected_descriptor"] - assert defs["GatewayQualityAck"]["required"][-3:] == ["revision", "lease_generation", "outcome"] - assert defs["GatewayQualityAck"]["properties"]["outcome"]["enum"] == ["applied", "proven_prior", "unknown"] + quality_ack = defs["GatewayQualityAck"] + assert quality_ack["required"][-3:] == ["revision", "lease_generation", "outcome"] + assert quality_ack["properties"]["outcome"]["enum"] == ["applied", "proven_prior", "unknown"] + assert quality_ack["description"] == ( + "Outcome invariants: applied requires current_applied_revision equal to revision; " + "proven_prior requires current_applied_revision strictly less than revision; " + "unknown forbids current_applied_revision and makes no applied-revision assertion." + ) + assert quality_ack["oneOf"] == [ + {"properties": {"outcome": {"const": "applied"}}, "required": ["current_applied_revision"]}, + {"properties": {"outcome": {"const": "proven_prior"}}, "required": ["current_applied_revision"]}, + {"properties": {"outcome": {"const": "unknown"}}, "not": {"required": ["current_applied_revision"]}}, + ] + ack_branches = quality_ack["oneOf"] + for valid_ack in ( + {"outcome": "applied", "current_applied_revision": 7}, + {"outcome": "proven_prior", "current_applied_revision": 6}, + {"outcome": "unknown"}, + ): + assert sum(matches_outcome_branch(valid_ack, branch) for branch in ack_branches) == 1, valid_ack + for invalid_ack in ( + {"outcome": "applied"}, + {"outcome": "proven_prior"}, + {"outcome": "unknown", "current_applied_revision": 6}, + ): + assert not any(matches_outcome_branch(invalid_ack, branch) for branch in ack_branches), invalid_ack operation_id_pattern = r"^(?!00000000-0000-0000-0000-000000000000$)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" canonical_time_pattern = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{0,8}[1-9])?Z$" @@ -235,6 +270,9 @@ def main() -> int: assert "coordinates MUST match exactly" in quality_acquisition quality_acknowledgement = openapi.split(" operationId: acknowledgeGatewayQualityWork\n", 1)[1].split(" responses:\n", 1)[0] assert "Stale lease generations MUST be rejected" in quality_acknowledgement + assert "`applied` requires `current_applied_revision == revision`" in quality_acknowledgement + assert "`proven_prior` requires `current_applied_revision < revision`" in quality_acknowledgement + assert "`unknown` forbids `current_applied_revision`" in quality_acknowledgement for operation_id in ("createSessionQualityChange", "getSessionQualityChange", "createSessionStopOperation", "getSessionStopOperation"): operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0] assert "owning principal and active device/key" in operation, operation_id