diff --git a/fixtures/invalid/gateway-quality-ack-contradictory.json b/fixtures/invalid/gateway-quality-ack-contradictory.json new file mode 100644 index 0000000..9af6628 --- /dev/null +++ b/fixtures/invalid/gateway-quality-ack-contradictory.json @@ -0,0 +1 @@ +{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":2,"operation_id":"12345678-1234-1234-1234-123456789abc","revision":7,"lease_generation":3,"outcome":"applied","current_applied_revision":6} diff --git a/fixtures/invalid/gateway-quality-ack-uppercase-uuid.json b/fixtures/invalid/gateway-quality-ack-uppercase-uuid.json new file mode 100644 index 0000000..639b2a1 --- /dev/null +++ b/fixtures/invalid/gateway-quality-ack-uppercase-uuid.json @@ -0,0 +1 @@ +{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":2,"operation_id":"12345678-1234-1234-1234-123456789ABC","revision":7,"lease_generation":3,"outcome":"unknown"} diff --git a/fixtures/invalid/gateway-quality-ack-zero-uuid.json b/fixtures/invalid/gateway-quality-ack-zero-uuid.json new file mode 100644 index 0000000..679e0a4 --- /dev/null +++ b/fixtures/invalid/gateway-quality-ack-zero-uuid.json @@ -0,0 +1 @@ +{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":2,"operation_id":"00000000-0000-0000-0000-000000000000","revision":7,"lease_generation":3,"outcome":"unknown"} diff --git a/fixtures/invalid/gateway-quality-poll-coordinates.json b/fixtures/invalid/gateway-quality-poll-coordinates.json new file mode 100644 index 0000000..283ba8d --- /dev/null +++ b/fixtures/invalid/gateway-quality-poll-coordinates.json @@ -0,0 +1 @@ +{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":2,"acquisition":"poll","operation_id":"12345678-1234-1234-1234-123456789abc","revision":7} diff --git a/fixtures/invalid/quality-operation-noncanonical-fraction.json b/fixtures/invalid/quality-operation-noncanonical-fraction.json new file mode 100644 index 0000000..295f88d --- /dev/null +++ b/fixtures/invalid/quality-operation-noncanonical-fraction.json @@ -0,0 +1 @@ +{"operation_id":"12345678-1234-1234-1234-123456789abc","session_id":"session-1","revision":7,"state":"pending","requested_bitrate_preference":{"mode":"auto"},"effective_bitrate_kbps":20000,"governing_policy_version":"policy-1","session_version":2,"created_at":"2099-01-01T00:00:00.100Z","deadline_at":"2099-01-01T00:00:30Z","updated_at":"2099-01-01T00:00:00Z"} diff --git a/fixtures/invalid/quality-operation-offset-time.json b/fixtures/invalid/quality-operation-offset-time.json new file mode 100644 index 0000000..60a29d5 --- /dev/null +++ b/fixtures/invalid/quality-operation-offset-time.json @@ -0,0 +1 @@ +{"operation_id":"12345678-1234-1234-1234-123456789abc","session_id":"session-1","revision":7,"state":"pending","requested_bitrate_preference":{"mode":"auto"},"effective_bitrate_kbps":20000,"governing_policy_version":"policy-1","session_version":2,"created_at":"2099-01-01T00:00:00+00:00","deadline_at":"2099-01-01T00:00:30Z","updated_at":"2099-01-01T00:00:00Z"} diff --git a/fixtures/manifest.json b/fixtures/manifest.json index cd40de3..cd38c7c 100644 --- a/fixtures/manifest.json +++ b/fixtures/manifest.json @@ -17,20 +17,28 @@ "fixtures/invalid/bitrate-preference-auto-target.json", "fixtures/invalid/browser-session-native-identity.json", "fixtures/invalid/capability-rc5-opaque.json", + "fixtures/invalid/gateway-quality-ack-contradictory.json", + "fixtures/invalid/gateway-quality-ack-uppercase-uuid.json", + "fixtures/invalid/gateway-quality-ack-zero-uuid.json", + "fixtures/invalid/gateway-quality-poll-coordinates.json", "fixtures/invalid/manifest-provider-field.json", "fixtures/invalid/native-session-missing-identity.json", "fixtures/invalid/provider-stream-policy-rc5.json", + "fixtures/invalid/quality-operation-noncanonical-fraction.json", + "fixtures/invalid/quality-operation-offset-time.json", "fixtures/invalid/selected-session-descriptor-provider-field.json", "fixtures/invalid/session-request-policy-snapshot.json", "fixtures/invalid/session-request-rc5.json", "fixtures/invalid/video-profile-unknown.json", "fixtures/valid/authenticated-browser-session.json", "fixtures/valid/authenticated-native-session.json", + "fixtures/valid/gateway-quality-ack-applied.json", + "fixtures/valid/gateway-quality-poll.json", "fixtures/valid/manifest.json", "fixtures/valid/native-tunnel-credential.json", "fixtures/valid/selected-session-descriptor.json", "fixtures/valid/session-quality-limits.json", "fixtures/valid/session-request.json" ], - "json_corpus_sha256": "2b111ca1c1723d6063aeafbf08c4e77972b16422ac3a84a054170039b2eff4d3" + "json_corpus_sha256": "08dea278526a523d21ea4ca6270ba1e33d44dc0d701681917d7ffc6fb13053d7" } diff --git a/fixtures/valid/gateway-quality-ack-applied.json b/fixtures/valid/gateway-quality-ack-applied.json new file mode 100644 index 0000000..6cba1de --- /dev/null +++ b/fixtures/valid/gateway-quality-ack-applied.json @@ -0,0 +1 @@ +{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":2,"operation_id":"12345678-1234-1234-1234-123456789abc","revision":7,"lease_generation":3,"outcome":"applied","current_applied_revision":7} diff --git a/fixtures/valid/gateway-quality-poll.json b/fixtures/valid/gateway-quality-poll.json new file mode 100644 index 0000000..5732fe6 --- /dev/null +++ b/fixtures/valid/gateway-quality-poll.json @@ -0,0 +1 @@ +{"version":"1","session_id":"session-1","gateway_id":"gateway-1","reconnect_sequence":2,"acquisition":"poll"} diff --git a/gen/go/protocol/protocol.go b/gen/go/protocol/protocol.go index f111eb4..936d30b 100644 --- a/gen/go/protocol/protocol.go +++ b/gen/go/protocol/protocol.go @@ -14,7 +14,7 @@ import ( "time" ) -const SchemaSHA256 = "614fa11dd1f49b8e10bf21b8c10eadbc1468d16bfcc6ed0a26d29671a5e61300" +const SchemaSHA256 = "8c1ed430127cf5774a919f14ff4770b2509d322c8518bf30c412e1e89eeffced" const ProtocolVersion = "1.0.0" const CurrentWireVersion = "2" const NMinus1WireVersion = "1" @@ -330,8 +330,9 @@ type GatewayQualityAck struct { ReconnectSequence int64 `json:"reconnect_sequence"` OperationID string `json:"operation_id"` Revision int64 `json:"revision"` + LeaseGeneration int64 `json:"lease_generation"` Outcome string `json:"outcome"` - CurrentAppliedRevision int64 `json:"current_applied_revision"` + CurrentAppliedRevision *int64 `json:"current_applied_revision,omitempty"` FailureCode string `json:"failure_code,omitempty"` } @@ -342,6 +343,7 @@ type GatewayQualityWork struct { ReconnectSequence int64 `json:"reconnect_sequence"` OperationID string `json:"operation_id"` Revision int64 `json:"revision"` + LeaseGeneration int64 `json:"lease_generation"` LeaseExpiresAt string `json:"lease_expires_at"` SelectedDescriptor SelectedSessionDescriptor `json:"selected_descriptor"` CurrentAppliedRevision *int64 `json:"current_applied_revision,omitempty"` @@ -352,8 +354,10 @@ type GatewayQualityWorkRequest struct { SessionID string `json:"session_id"` GatewayID string `json:"gateway_id"` ReconnectSequence int64 `json:"reconnect_sequence"` - OperationID string `json:"operation_id"` - Revision int64 `json:"revision"` + Acquisition string `json:"acquisition"` + OperationID string `json:"operation_id,omitempty"` + Revision *int64 `json:"revision,omitempty"` + LeaseGeneration *int64 `json:"lease_generation,omitempty"` CurrentAppliedRevision *int64 `json:"current_applied_revision,omitempty"` } @@ -397,7 +401,8 @@ type GatewayStopWorkRequest struct { SessionID string `json:"session_id"` GatewayID string `json:"gateway_id"` ReconnectSequence int64 `json:"reconnect_sequence"` - OperationID string `json:"operation_id"` + Acquisition string `json:"acquisition"` + OperationID string `json:"operation_id,omitempty"` } type GatewayTelemetry struct { @@ -3487,13 +3492,19 @@ func (v GatewayQualityAck) Validate() error { if v.Revision != 0 && v.Revision < 1 { violations = append(violations, FieldViolation{Field: "revision", Code: "minimum"}) } + if v.LeaseGeneration == 0 { + violations = append(violations, FieldViolation{Field: "lease_generation", Code: "required"}) + } + if v.LeaseGeneration != 0 && v.LeaseGeneration < 1 { + violations = append(violations, FieldViolation{Field: "lease_generation", Code: "minimum"}) + } if v.Outcome == "" { violations = append(violations, FieldViolation{Field: "outcome", Code: "required"}) } - if v.Outcome != "" && !(v.Outcome == "applied" || v.Outcome == "not_applied" || v.Outcome == "uncertain") { + if v.Outcome != "" && !(v.Outcome == "applied" || v.Outcome == "proven_prior" || v.Outcome == "unknown") { violations = append(violations, FieldViolation{Field: "outcome", Code: "invalid_value"}) } - if v.CurrentAppliedRevision != 0 && v.CurrentAppliedRevision < 0 { + if v.CurrentAppliedRevision != nil && *v.CurrentAppliedRevision != 0 && *v.CurrentAppliedRevision < 0 { violations = append(violations, FieldViolation{Field: "current_applied_revision", Code: "minimum"}) } if len(v.FailureCode) < 1 && v.FailureCode != "" { @@ -3502,6 +3513,15 @@ func (v GatewayQualityAck) Validate() error { if len(v.FailureCode) > 128 { violations = append(violations, FieldViolation{Field: "failure_code", Code: "max_length"}) } + if v.Outcome == "applied" && (v.CurrentAppliedRevision == nil || *v.CurrentAppliedRevision != v.Revision) { + violations = append(violations, FieldViolation{Field: "current_applied_revision", Code: "invalid_tagged_value"}) + } + if v.Outcome == "proven_prior" && (v.CurrentAppliedRevision == nil || *v.CurrentAppliedRevision >= v.Revision) { + violations = append(violations, FieldViolation{Field: "current_applied_revision", Code: "invalid_tagged_value"}) + } + if v.Outcome == "unknown" && v.CurrentAppliedRevision != nil { + violations = append(violations, FieldViolation{Field: "current_applied_revision", Code: "invalid_tagged_value"}) + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -3520,12 +3540,12 @@ func DecodeGatewayQualityAck(data []byte) (GatewayQualityAck, error) { if err := json.Unmarshal(data, &fields); err != nil { return value, err } - if raw, ok := fields["current_applied_revision"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return value, ValidationError{Violations: []FieldViolation{{Field: "current_applied_revision", Code: "required"}}} - } if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} } + if raw, ok := fields["lease_generation"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "lease_generation", Code: "required"}}} + } if raw, ok := fields["operation_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "operation_id", Code: "required"}}} } @@ -3616,6 +3636,12 @@ func (v GatewayQualityWork) Validate() error { if v.Revision != 0 && v.Revision < 1 { violations = append(violations, FieldViolation{Field: "revision", Code: "minimum"}) } + if v.LeaseGeneration == 0 { + violations = append(violations, FieldViolation{Field: "lease_generation", Code: "required"}) + } + if v.LeaseGeneration != 0 && v.LeaseGeneration < 1 { + violations = append(violations, FieldViolation{Field: "lease_generation", Code: "minimum"}) + } if v.LeaseExpiresAt == "" { violations = append(violations, FieldViolation{Field: "lease_expires_at", Code: "required"}) } @@ -3660,6 +3686,9 @@ func DecodeGatewayQualityWork(data []byte) (GatewayQualityWork, error) { if raw, ok := fields["lease_expires_at"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "lease_expires_at", Code: "required"}}} } + if raw, ok := fields["lease_generation"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "lease_generation", Code: "required"}}} + } if raw, ok := fields["operation_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "operation_id", Code: "required"}}} } @@ -3732,8 +3761,11 @@ func (v GatewayQualityWorkRequest) Validate() error { if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 { violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"}) } - if v.OperationID == "" { - violations = append(violations, FieldViolation{Field: "operation_id", Code: "required"}) + if v.Acquisition == "" { + violations = append(violations, FieldViolation{Field: "acquisition", Code: "required"}) + } + if v.Acquisition != "" && !(v.Acquisition == "poll" || v.Acquisition == "prompt" || v.Acquisition == "observation") { + violations = append(violations, FieldViolation{Field: "acquisition", Code: "invalid_value"}) } if len(v.OperationID) < 36 && v.OperationID != "" { violations = append(violations, FieldViolation{Field: "operation_id", Code: "min_length"}) @@ -3744,15 +3776,24 @@ func (v GatewayQualityWorkRequest) Validate() error { if v.OperationID != "" && !validCanonicalUUID(v.OperationID) { violations = append(violations, FieldViolation{Field: "operation_id", Code: "invalid_uuid"}) } - if v.Revision == 0 { - violations = append(violations, FieldViolation{Field: "revision", Code: "required"}) - } - if v.Revision != 0 && v.Revision < 1 { + if v.Revision != nil && *v.Revision != 0 && *v.Revision < 1 { violations = append(violations, FieldViolation{Field: "revision", Code: "minimum"}) } + if v.LeaseGeneration != nil && *v.LeaseGeneration != 0 && *v.LeaseGeneration < 1 { + violations = append(violations, FieldViolation{Field: "lease_generation", Code: "minimum"}) + } if v.CurrentAppliedRevision != nil && *v.CurrentAppliedRevision != 0 && *v.CurrentAppliedRevision < 0 { violations = append(violations, FieldViolation{Field: "current_applied_revision", Code: "minimum"}) } + if v.Acquisition == "poll" && (v.OperationID != "" || v.Revision != nil || v.LeaseGeneration != nil || v.CurrentAppliedRevision != nil) { + violations = append(violations, FieldViolation{Field: "acquisition", Code: "invalid_tagged_value"}) + } + if v.Acquisition == "prompt" && (v.OperationID == "" || v.Revision == nil || v.LeaseGeneration != nil || v.CurrentAppliedRevision != nil) { + violations = append(violations, FieldViolation{Field: "acquisition", Code: "invalid_tagged_value"}) + } + if v.Acquisition == "observation" && (v.OperationID == "" || v.Revision == nil || v.LeaseGeneration == nil || v.CurrentAppliedRevision == nil) { + violations = append(violations, FieldViolation{Field: "acquisition", Code: "invalid_tagged_value"}) + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -3771,18 +3812,15 @@ func DecodeGatewayQualityWorkRequest(data []byte) (GatewayQualityWorkRequest, er if err := json.Unmarshal(data, &fields); err != nil { return value, err } + if raw, ok := fields["acquisition"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "acquisition", Code: "required"}}} + } if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} } - if raw, ok := fields["operation_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return value, ValidationError{Violations: []FieldViolation{{Field: "operation_id", Code: "required"}}} - } if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} } - if raw, ok := fields["revision"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return value, ValidationError{Violations: []FieldViolation{{Field: "revision", Code: "required"}}} - } if raw, ok := fields["session_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "session_id", Code: "required"}}} } @@ -4273,8 +4311,11 @@ func (v GatewayStopWorkRequest) Validate() error { if v.ReconnectSequence != 0 && v.ReconnectSequence < 0 { violations = append(violations, FieldViolation{Field: "reconnect_sequence", Code: "minimum"}) } - if v.OperationID == "" { - violations = append(violations, FieldViolation{Field: "operation_id", Code: "required"}) + if v.Acquisition == "" { + violations = append(violations, FieldViolation{Field: "acquisition", Code: "required"}) + } + if v.Acquisition != "" && !(v.Acquisition == "poll" || v.Acquisition == "prompt") { + violations = append(violations, FieldViolation{Field: "acquisition", Code: "invalid_value"}) } if len(v.OperationID) < 36 && v.OperationID != "" { violations = append(violations, FieldViolation{Field: "operation_id", Code: "min_length"}) @@ -4285,6 +4326,9 @@ func (v GatewayStopWorkRequest) Validate() error { if v.OperationID != "" && !validCanonicalUUID(v.OperationID) { violations = append(violations, FieldViolation{Field: "operation_id", Code: "invalid_uuid"}) } + if v.Acquisition == "poll" && v.OperationID != "" || v.Acquisition == "prompt" && v.OperationID == "" { + violations = append(violations, FieldViolation{Field: "acquisition", Code: "invalid_tagged_value"}) + } if len(violations) > 0 { return ValidationError{Violations: violations} } @@ -4303,12 +4347,12 @@ func DecodeGatewayStopWorkRequest(data []byte) (GatewayStopWorkRequest, error) { if err := json.Unmarshal(data, &fields); err != nil { return value, err } + if raw, ok := fields["acquisition"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return value, ValidationError{Violations: []FieldViolation{{Field: "acquisition", Code: "required"}}} + } if raw, ok := fields["gateway_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "gateway_id", Code: "required"}}} } - if raw, ok := fields["operation_id"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - return value, ValidationError{Violations: []FieldViolation{{Field: "operation_id", Code: "required"}}} - } if raw, ok := fields["reconnect_sequence"]; !ok || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { return value, ValidationError{Violations: []FieldViolation{{Field: "reconnect_sequence", Code: "required"}}} } diff --git a/gen/manifest.json b/gen/manifest.json index 74e5ef6..cf13779 100644 --- a/gen/manifest.json +++ b/gen/manifest.json @@ -12,7 +12,7 @@ "3" ] }, - "generator_sha256": "9f6e95fac0f3c8389d8111e6ba0a03b48e155b71e345a11640ef824b8b3a4910", + "generator_sha256": "c5756780f59a82d8f56762e52f58e22a4d6aca6648157f45e5bad6559b9deeec", "protocol_version": "1.0.0", - "schema_sha256": "614fa11dd1f49b8e10bf21b8c10eadbc1468d16bfcc6ed0a26d29671a5e61300" + "schema_sha256": "8c1ed430127cf5774a919f14ff4770b2509d322c8518bf30c412e1e89eeffced" } diff --git a/gen/protobuf/tunnel-v1.pb b/gen/protobuf/tunnel-v1.pb index 4c89b2b..cbc359e 100644 Binary files a/gen/protobuf/tunnel-v1.pb and b/gen/protobuf/tunnel-v1.pb differ diff --git a/gen/rust/protocol.rs b/gen/rust/protocol.rs index 202d9c4..589a881 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 = "614fa11dd1f49b8e10bf21b8c10eadbc1468d16bfcc6ed0a26d29671a5e61300"; +pub const SCHEMA_SHA256: &str = "8c1ed430127cf5774a919f14ff4770b2509d322c8518bf30c412e1e89eeffced"; 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"; @@ -1055,14 +1055,15 @@ pub struct GatewayQualityAck { reconnectSequence: i64, operationId: String, revision: i64, + leaseGeneration: i64, outcome: String, - currentAppliedRevision: i64, + currentAppliedRevision: Option, failureCode: Option, } impl GatewayQualityAck { - pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, operationId: String, revision: i64, outcome: String, currentAppliedRevision: i64, failureCode: Option) -> Result { - let value = Self { version, sessionId, gatewayId, reconnectSequence, operationId, revision, outcome, currentAppliedRevision, failureCode }; + pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, operationId: String, revision: i64, leaseGeneration: i64, outcome: String, currentAppliedRevision: Option, failureCode: Option) -> Result { + let value = Self { version, sessionId, gatewayId, reconnectSequence, operationId, revision, leaseGeneration, outcome, currentAppliedRevision, failureCode }; value.validate()?; Ok(value) } @@ -1080,12 +1081,18 @@ impl GatewayQualityAck { if self.operationId.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } if !valid_canonical_uuid(self.operationId.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } if self.revision < 1 { return Err(ValidationError::new("revision", "minimum")); } - if self.outcome != "applied" && self.outcome != "not_applied" && self.outcome != "uncertain" { return Err(ValidationError::new("outcome", "invalid_value")); } - if self.currentAppliedRevision < 0 { return Err(ValidationError::new("current_applied_revision", "minimum")); } + if self.leaseGeneration < 1 { return Err(ValidationError::new("lease_generation", "minimum")); } + if self.outcome != "applied" && self.outcome != "proven_prior" && self.outcome != "unknown" { return Err(ValidationError::new("outcome", "invalid_value")); } + if let Some(value) = &self.currentAppliedRevision { + if *value < 0 { return Err(ValidationError::new("current_applied_revision", "minimum")); } + } if let Some(value) = &self.failureCode { if !value.is_empty() && value.len() < 1 { return Err(ValidationError::new("failure_code", "min_length")); } if value.len() > 128 { return Err(ValidationError::new("failure_code", "max_length")); } } + if self.outcome == "applied" && self.currentAppliedRevision != Some(self.revision) { return Err(ValidationError::new("current_applied_revision", "invalid_tagged_value")); } + if self.outcome == "proven_prior" && self.currentAppliedRevision.map_or(true, |current| current >= self.revision) { return Err(ValidationError::new("current_applied_revision", "invalid_tagged_value")); } + if self.outcome == "unknown" && self.currentAppliedRevision.is_some() { return Err(ValidationError::new("current_applied_revision", "invalid_tagged_value")); } Ok(()) } pub fn version(&self) -> &String { &self.version } @@ -1094,8 +1101,9 @@ impl GatewayQualityAck { pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence } pub fn operationId(&self) -> &String { &self.operationId } pub fn revision(&self) -> &i64 { &self.revision } + pub fn leaseGeneration(&self) -> &i64 { &self.leaseGeneration } pub fn outcome(&self) -> &String { &self.outcome } - pub fn currentAppliedRevision(&self) -> &i64 { &self.currentAppliedRevision } + pub fn currentAppliedRevision(&self) -> &Option { &self.currentAppliedRevision } pub fn failureCode(&self) -> &Option { &self.failureCode } } @@ -1107,14 +1115,15 @@ pub struct GatewayQualityWork { reconnectSequence: i64, operationId: String, revision: i64, + leaseGeneration: i64, leaseExpiresAt: String, selectedDescriptor: SelectedSessionDescriptor, currentAppliedRevision: Option, } impl GatewayQualityWork { - pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, operationId: String, revision: i64, leaseExpiresAt: String, selectedDescriptor: SelectedSessionDescriptor, currentAppliedRevision: Option) -> Result { - let value = Self { version, sessionId, gatewayId, reconnectSequence, operationId, revision, leaseExpiresAt, selectedDescriptor, currentAppliedRevision }; + pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, operationId: String, revision: i64, leaseGeneration: i64, leaseExpiresAt: String, selectedDescriptor: SelectedSessionDescriptor, currentAppliedRevision: Option) -> Result { + let value = Self { version, sessionId, gatewayId, reconnectSequence, operationId, revision, leaseGeneration, leaseExpiresAt, selectedDescriptor, currentAppliedRevision }; value.validate()?; Ok(value) } @@ -1132,6 +1141,7 @@ impl GatewayQualityWork { if self.operationId.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } if !valid_canonical_uuid(self.operationId.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } if self.revision < 1 { return Err(ValidationError::new("revision", "minimum")); } + if self.leaseGeneration < 1 { return Err(ValidationError::new("lease_generation", "minimum")); } if self.leaseExpiresAt.len() > 64 { return Err(ValidationError::new("lease_expires_at", "max_length")); } if !valid_rfc3339_utc(self.leaseExpiresAt.as_str()) { return Err(ValidationError::new("lease_expires_at", "invalid_time")); } self.selectedDescriptor.validate().map_err(|_| ValidationError::new("selected_descriptor", "invalid_object"))?; @@ -1146,6 +1156,7 @@ impl GatewayQualityWork { pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence } pub fn operationId(&self) -> &String { &self.operationId } pub fn revision(&self) -> &i64 { &self.revision } + pub fn leaseGeneration(&self) -> &i64 { &self.leaseGeneration } pub fn leaseExpiresAt(&self) -> &String { &self.leaseExpiresAt } pub fn selectedDescriptor(&self) -> &SelectedSessionDescriptor { &self.selectedDescriptor } pub fn currentAppliedRevision(&self) -> &Option { &self.currentAppliedRevision } @@ -1157,14 +1168,16 @@ pub struct GatewayQualityWorkRequest { sessionId: String, gatewayId: String, reconnectSequence: i64, - operationId: String, - revision: i64, + acquisition: String, + operationId: Option, + revision: Option, + leaseGeneration: Option, currentAppliedRevision: Option, } impl GatewayQualityWorkRequest { - pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, operationId: String, revision: i64, currentAppliedRevision: Option) -> Result { - let value = Self { version, sessionId, gatewayId, reconnectSequence, operationId, revision, currentAppliedRevision }; + pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, acquisition: String, operationId: Option, revision: Option, leaseGeneration: Option, currentAppliedRevision: Option) -> Result { + let value = Self { version, sessionId, gatewayId, reconnectSequence, acquisition, operationId, revision, leaseGeneration, currentAppliedRevision }; value.validate()?; Ok(value) } @@ -1177,22 +1190,34 @@ impl GatewayQualityWorkRequest { if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); } if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); } if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); } - if self.operationId.is_empty() { return Err(ValidationError::new("operation_id", "required")); } - if !self.operationId.is_empty() && self.operationId.len() < 36 { return Err(ValidationError::new("operation_id", "min_length")); } - if self.operationId.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } - if !valid_canonical_uuid(self.operationId.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } - if self.revision < 1 { return Err(ValidationError::new("revision", "minimum")); } + if self.acquisition != "poll" && self.acquisition != "prompt" && self.acquisition != "observation" { return Err(ValidationError::new("acquisition", "invalid_value")); } + if let Some(value) = &self.operationId { + if !value.is_empty() && value.len() < 36 { return Err(ValidationError::new("operation_id", "min_length")); } + if value.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } + if !valid_canonical_uuid(value.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } + } + if let Some(value) = &self.revision { + if *value < 1 { return Err(ValidationError::new("revision", "minimum")); } + } + if let Some(value) = &self.leaseGeneration { + if *value < 1 { return Err(ValidationError::new("lease_generation", "minimum")); } + } if let Some(value) = &self.currentAppliedRevision { if *value < 0 { return Err(ValidationError::new("current_applied_revision", "minimum")); } } + if self.acquisition == "poll" && (self.operationId.is_some() || self.revision.is_some() || self.leaseGeneration.is_some() || self.currentAppliedRevision.is_some()) { return Err(ValidationError::new("acquisition", "invalid_tagged_value")); } + if self.acquisition == "prompt" && (self.operationId.is_none() || self.revision.is_none() || self.leaseGeneration.is_some() || self.currentAppliedRevision.is_some()) { return Err(ValidationError::new("acquisition", "invalid_tagged_value")); } + if self.acquisition == "observation" && (self.operationId.is_none() || self.revision.is_none() || self.leaseGeneration.is_none() || self.currentAppliedRevision.is_none()) { return Err(ValidationError::new("acquisition", "invalid_tagged_value")); } Ok(()) } pub fn version(&self) -> &String { &self.version } pub fn sessionId(&self) -> &String { &self.sessionId } pub fn gatewayId(&self) -> &String { &self.gatewayId } pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence } - pub fn operationId(&self) -> &String { &self.operationId } - pub fn revision(&self) -> &i64 { &self.revision } + pub fn acquisition(&self) -> &String { &self.acquisition } + pub fn operationId(&self) -> &Option { &self.operationId } + pub fn revision(&self) -> &Option { &self.revision } + pub fn leaseGeneration(&self) -> &Option { &self.leaseGeneration } pub fn currentAppliedRevision(&self) -> &Option { &self.currentAppliedRevision } } @@ -1362,12 +1387,13 @@ pub struct GatewayStopWorkRequest { sessionId: String, gatewayId: String, reconnectSequence: i64, - operationId: String, + acquisition: String, + operationId: Option, } impl GatewayStopWorkRequest { - pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, operationId: String) -> Result { - let value = Self { version, sessionId, gatewayId, reconnectSequence, operationId }; + pub fn new(version: String, sessionId: String, gatewayId: String, reconnectSequence: i64, acquisition: String, operationId: Option) -> Result { + let value = Self { version, sessionId, gatewayId, reconnectSequence, acquisition, operationId }; value.validate()?; Ok(value) } @@ -1380,17 +1406,21 @@ impl GatewayStopWorkRequest { if !self.gatewayId.is_empty() && self.gatewayId.len() < 1 { return Err(ValidationError::new("gateway_id", "min_length")); } if self.gatewayId.len() > 128 { return Err(ValidationError::new("gateway_id", "max_length")); } if self.reconnectSequence < 0 { return Err(ValidationError::new("reconnect_sequence", "minimum")); } - if self.operationId.is_empty() { return Err(ValidationError::new("operation_id", "required")); } - if !self.operationId.is_empty() && self.operationId.len() < 36 { return Err(ValidationError::new("operation_id", "min_length")); } - if self.operationId.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } - if !valid_canonical_uuid(self.operationId.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } + if self.acquisition != "poll" && self.acquisition != "prompt" { return Err(ValidationError::new("acquisition", "invalid_value")); } + if let Some(value) = &self.operationId { + if !value.is_empty() && value.len() < 36 { return Err(ValidationError::new("operation_id", "min_length")); } + if value.len() > 36 { return Err(ValidationError::new("operation_id", "max_length")); } + if !valid_canonical_uuid(value.as_str()) { return Err(ValidationError::new("operation_id", "invalid_uuid")); } + } + if self.acquisition == "poll" && self.operationId.is_some() || self.acquisition == "prompt" && self.operationId.is_none() { return Err(ValidationError::new("acquisition", "invalid_tagged_value")); } Ok(()) } pub fn version(&self) -> &String { &self.version } pub fn sessionId(&self) -> &String { &self.sessionId } pub fn gatewayId(&self) -> &String { &self.gatewayId } pub fn reconnectSequence(&self) -> &i64 { &self.reconnectSequence } - pub fn operationId(&self) -> &String { &self.operationId } + pub fn acquisition(&self) -> &String { &self.acquisition } + pub fn operationId(&self) -> &Option { &self.operationId } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/gen/swift/Protocol.swift b/gen/swift/Protocol.swift index 9521c12..485fb33 100644 --- a/gen/swift/Protocol.swift +++ b/gen/swift/Protocol.swift @@ -1,13 +1,14 @@ // Code generated by tools/generate.py; DO NOT EDIT. import Foundation public typealias JSONObject = [String: String] -public let schemaSHA256 = "614fa11dd1f49b8e10bf21b8c10eadbc1468d16bfcc6ed0a26d29671a5e61300" +public let schemaSHA256 = "8c1ed430127cf5774a919f14ff4770b2509d322c8518bf30c412e1e89eeffced" public let currentWireVersion = "2" public let nMinus1WireVersion = "1" public let nMinus2WireVersion = "0" public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String } private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } } private func rejectDuplicateJSONKeys(_ data: Data) throws { + guard data.count <= 1_048_576 else { throw ContractValidationError(field: "json", code: "payload_too_large") } var index = 0 func skipWhitespace() { while index < data.count && [9, 10, 13, 32].contains(data[index]) { index += 1 } } func parseString() throws -> String { @@ -21,7 +22,8 @@ private func rejectDuplicateJSONKeys(_ data: Data) throws { } throw ContractValidationError(field: "json", code: "invalid_json") } - func parseValue() throws { + func parseValue(_ depth: Int) throws { + guard depth <= 64 else { throw ContractValidationError(field: "json", code: "nesting_too_deep") } skipWhitespace() guard index < data.count else { throw ContractValidationError(field: "json", code: "invalid_json") } if data[index] == 123 { @@ -36,7 +38,7 @@ private func rejectDuplicateJSONKeys(_ data: Data) throws { skipWhitespace() guard index < data.count, data[index] == 58 else { throw ContractValidationError(field: "json", code: "invalid_json") } index += 1 - try parseValue() + try parseValue(depth + 1) skipWhitespace() guard index < data.count else { throw ContractValidationError(field: "json", code: "invalid_json") } if data[index] == 125 { index += 1; return } @@ -49,7 +51,7 @@ private func rejectDuplicateJSONKeys(_ data: Data) throws { skipWhitespace() if index < data.count, data[index] == 93 { index += 1; return } while true { - try parseValue() + try parseValue(depth + 1) skipWhitespace() guard index < data.count else { throw ContractValidationError(field: "json", code: "invalid_json") } if data[index] == 93 { index += 1; return } @@ -62,7 +64,7 @@ private func rejectDuplicateJSONKeys(_ data: Data) throws { while index < data.count && ![9, 10, 13, 32, 44, 93, 125].contains(data[index]) { index += 1 } guard index > start else { throw ContractValidationError(field: "json", code: "invalid_json") } } - try parseValue() + try parseValue(0) skipWhitespace() guard index == data.count else { throw ContractValidationError(field: "json", code: "trailing_json") } } @@ -1434,8 +1436,9 @@ public struct GatewayQualityAck: Codable, Equatable { public let reconnectSequence: Int64 public let operationId: String public let revision: Int64 + public let leaseGeneration: Int64 public let outcome: String - public let currentAppliedRevision: Int64 + public let currentAppliedRevision: Int64? public let failureCode: String? enum CodingKeys: String, CodingKey { case version = "version" @@ -1444,18 +1447,20 @@ public struct GatewayQualityAck: Codable, Equatable { case reconnectSequence = "reconnect_sequence" case operationId = "operation_id" case revision = "revision" + case leaseGeneration = "lease_generation" case outcome = "outcome" case currentAppliedRevision = "current_applied_revision" case failureCode = "failure_code" } - public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, operationId: String, revision: Int64, outcome: String, currentAppliedRevision: Int64, failureCode: String?) throws { + public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, operationId: String, revision: Int64, leaseGeneration: Int64, outcome: String, currentAppliedRevision: Int64?, failureCode: String?) throws { self.version = version self.sessionId = sessionId self.gatewayId = gatewayId self.reconnectSequence = reconnectSequence self.operationId = operationId self.revision = revision + self.leaseGeneration = leaseGeneration self.outcome = outcome self.currentAppliedRevision = currentAppliedRevision self.failureCode = failureCode @@ -1466,7 +1471,7 @@ public struct GatewayQualityAck: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), operationId: try c.decode(String.self, forKey: .operationId), revision: try c.decode(Int64.self, forKey: .revision), outcome: try c.decode(String.self, forKey: .outcome), currentAppliedRevision: try c.decode(Int64.self, forKey: .currentAppliedRevision), failureCode: try c.decodeIfPresent(String.self, forKey: .failureCode)) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), operationId: try c.decode(String.self, forKey: .operationId), revision: try c.decode(Int64.self, forKey: .revision), leaseGeneration: try c.decode(Int64.self, forKey: .leaseGeneration), outcome: try c.decode(String.self, forKey: .outcome), currentAppliedRevision: try c.decodeIfPresent(Int64.self, forKey: .currentAppliedRevision), failureCode: try c.decodeIfPresent(String.self, forKey: .failureCode)) } public func validate() throws { @@ -1483,12 +1488,18 @@ public struct GatewayQualityAck: Codable, Equatable { if self.operationId.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } if !validCanonicalUUID(self.operationId) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } if self.revision < 1 { throw ContractValidationError(field: "revision", code: "minimum") } - if !["applied", "not_applied", "uncertain"].contains(self.outcome) { throw ContractValidationError(field: "outcome", code: "invalid_value") } - if self.currentAppliedRevision < 0 { throw ContractValidationError(field: "current_applied_revision", code: "minimum") } + if self.leaseGeneration < 1 { throw ContractValidationError(field: "lease_generation", code: "minimum") } + if !["applied", "proven_prior", "unknown"].contains(self.outcome) { throw ContractValidationError(field: "outcome", code: "invalid_value") } + if let value = self.currentAppliedRevision { + if value < 0 { throw ContractValidationError(field: "current_applied_revision", code: "minimum") } + } if let value = self.failureCode { if !value.isEmpty && value.utf8.count < 1 { throw ContractValidationError(field: "failure_code", code: "min_length") } if value.utf8.count > 128 { throw ContractValidationError(field: "failure_code", code: "max_length") } } + if outcome == "applied" && currentAppliedRevision != revision { throw ContractValidationError(field: "current_applied_revision", code: "invalid_tagged_value") } + if outcome == "proven_prior" && (currentAppliedRevision == nil || currentAppliedRevision! >= revision) { throw ContractValidationError(field: "current_applied_revision", code: "invalid_tagged_value") } + if outcome == "unknown" && currentAppliedRevision != nil { throw ContractValidationError(field: "current_applied_revision", code: "invalid_tagged_value") } } public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } @@ -1502,6 +1513,7 @@ public struct GatewayQualityWork: Codable, Equatable { public let reconnectSequence: Int64 public let operationId: String public let revision: Int64 + public let leaseGeneration: Int64 public let leaseExpiresAt: String public let selectedDescriptor: SelectedSessionDescriptor public let currentAppliedRevision: Int64? @@ -1512,18 +1524,20 @@ public struct GatewayQualityWork: Codable, Equatable { case reconnectSequence = "reconnect_sequence" case operationId = "operation_id" case revision = "revision" + case leaseGeneration = "lease_generation" case leaseExpiresAt = "lease_expires_at" case selectedDescriptor = "selected_descriptor" case currentAppliedRevision = "current_applied_revision" } - public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, operationId: String, revision: Int64, leaseExpiresAt: String, selectedDescriptor: SelectedSessionDescriptor, currentAppliedRevision: Int64?) throws { + public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, operationId: String, revision: Int64, leaseGeneration: Int64, leaseExpiresAt: String, selectedDescriptor: SelectedSessionDescriptor, currentAppliedRevision: Int64?) throws { self.version = version self.sessionId = sessionId self.gatewayId = gatewayId self.reconnectSequence = reconnectSequence self.operationId = operationId self.revision = revision + self.leaseGeneration = leaseGeneration self.leaseExpiresAt = leaseExpiresAt self.selectedDescriptor = selectedDescriptor self.currentAppliedRevision = currentAppliedRevision @@ -1534,7 +1548,7 @@ public struct GatewayQualityWork: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), operationId: try c.decode(String.self, forKey: .operationId), revision: try c.decode(Int64.self, forKey: .revision), leaseExpiresAt: try c.decode(String.self, forKey: .leaseExpiresAt), selectedDescriptor: try c.decode(SelectedSessionDescriptor.self, forKey: .selectedDescriptor), currentAppliedRevision: try c.decodeIfPresent(Int64.self, forKey: .currentAppliedRevision)) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), operationId: try c.decode(String.self, forKey: .operationId), revision: try c.decode(Int64.self, forKey: .revision), leaseGeneration: try c.decode(Int64.self, forKey: .leaseGeneration), leaseExpiresAt: try c.decode(String.self, forKey: .leaseExpiresAt), selectedDescriptor: try c.decode(SelectedSessionDescriptor.self, forKey: .selectedDescriptor), currentAppliedRevision: try c.decodeIfPresent(Int64.self, forKey: .currentAppliedRevision)) } public func validate() throws { @@ -1551,6 +1565,7 @@ public struct GatewayQualityWork: Codable, Equatable { if self.operationId.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } if !validCanonicalUUID(self.operationId) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } if self.revision < 1 { throw ContractValidationError(field: "revision", code: "minimum") } + if self.leaseGeneration < 1 { throw ContractValidationError(field: "lease_generation", code: "minimum") } if self.leaseExpiresAt.utf8.count > 64 { throw ContractValidationError(field: "lease_expires_at", code: "max_length") } if !validRFC3339UTC(self.leaseExpiresAt) { throw ContractValidationError(field: "lease_expires_at", code: "invalid_time") } try self.selectedDescriptor.validate() @@ -1568,26 +1583,32 @@ public struct GatewayQualityWorkRequest: Codable, Equatable { public let sessionId: String public let gatewayId: String public let reconnectSequence: Int64 - public let operationId: String - public let revision: Int64 + public let acquisition: String + public let operationId: String? + public let revision: Int64? + public let leaseGeneration: Int64? public let currentAppliedRevision: Int64? enum CodingKeys: String, CodingKey { case version = "version" case sessionId = "session_id" case gatewayId = "gateway_id" case reconnectSequence = "reconnect_sequence" + case acquisition = "acquisition" case operationId = "operation_id" case revision = "revision" + case leaseGeneration = "lease_generation" case currentAppliedRevision = "current_applied_revision" } - public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, operationId: String, revision: Int64, currentAppliedRevision: Int64?) throws { + public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, acquisition: String, operationId: String?, revision: Int64?, leaseGeneration: Int64?, currentAppliedRevision: Int64?) throws { self.version = version self.sessionId = sessionId self.gatewayId = gatewayId self.reconnectSequence = reconnectSequence + self.acquisition = acquisition self.operationId = operationId self.revision = revision + self.leaseGeneration = leaseGeneration self.currentAppliedRevision = currentAppliedRevision try validate() } @@ -1596,7 +1617,7 @@ public struct GatewayQualityWorkRequest: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), operationId: try c.decode(String.self, forKey: .operationId), revision: try c.decode(Int64.self, forKey: .revision), currentAppliedRevision: try c.decodeIfPresent(Int64.self, forKey: .currentAppliedRevision)) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), acquisition: try c.decode(String.self, forKey: .acquisition), operationId: try c.decodeIfPresent(String.self, forKey: .operationId), revision: try c.decodeIfPresent(Int64.self, forKey: .revision), leaseGeneration: try c.decodeIfPresent(Int64.self, forKey: .leaseGeneration), currentAppliedRevision: try c.decodeIfPresent(Int64.self, forKey: .currentAppliedRevision)) } public func validate() throws { @@ -1608,14 +1629,24 @@ public struct GatewayQualityWorkRequest: Codable, Equatable { if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") } if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") } if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") } - if self.operationId.isEmpty { throw ContractValidationError(field: "operation_id", code: "required") } - if !self.operationId.isEmpty && self.operationId.utf8.count < 36 { throw ContractValidationError(field: "operation_id", code: "min_length") } - if self.operationId.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } - if !validCanonicalUUID(self.operationId) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } - if self.revision < 1 { throw ContractValidationError(field: "revision", code: "minimum") } + if !["poll", "prompt", "observation"].contains(self.acquisition) { throw ContractValidationError(field: "acquisition", code: "invalid_value") } + if let value = self.operationId { + if !value.isEmpty && value.utf8.count < 36 { throw ContractValidationError(field: "operation_id", code: "min_length") } + if value.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } + if !validCanonicalUUID(value) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } + } + if let value = self.revision { + if value < 1 { throw ContractValidationError(field: "revision", code: "minimum") } + } + if let value = self.leaseGeneration { + if value < 1 { throw ContractValidationError(field: "lease_generation", code: "minimum") } + } if let value = self.currentAppliedRevision { if value < 0 { throw ContractValidationError(field: "current_applied_revision", code: "minimum") } } + if acquisition == "poll" && (operationId != nil || revision != nil || leaseGeneration != nil || currentAppliedRevision != nil) { throw ContractValidationError(field: "acquisition", code: "invalid_tagged_value") } + if acquisition == "prompt" && (operationId == nil || revision == nil || leaseGeneration != nil || currentAppliedRevision != nil) { throw ContractValidationError(field: "acquisition", code: "invalid_tagged_value") } + if acquisition == "observation" && (operationId == nil || revision == nil || leaseGeneration == nil || currentAppliedRevision == nil) { throw ContractValidationError(field: "acquisition", code: "invalid_tagged_value") } } public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } @@ -1834,20 +1865,23 @@ public struct GatewayStopWorkRequest: Codable, Equatable { public let sessionId: String public let gatewayId: String public let reconnectSequence: Int64 - public let operationId: String + public let acquisition: String + public let operationId: String? enum CodingKeys: String, CodingKey { case version = "version" case sessionId = "session_id" case gatewayId = "gateway_id" case reconnectSequence = "reconnect_sequence" + case acquisition = "acquisition" case operationId = "operation_id" } - public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, operationId: String) throws { + public init(version: String, sessionId: String, gatewayId: String, reconnectSequence: Int64, acquisition: String, operationId: String?) throws { self.version = version self.sessionId = sessionId self.gatewayId = gatewayId self.reconnectSequence = reconnectSequence + self.acquisition = acquisition self.operationId = operationId try validate() } @@ -1856,7 +1890,7 @@ public struct GatewayStopWorkRequest: Codable, Equatable { let all = try decoder.container(keyedBy: AnyCodingKey.self) for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: "unknown_field") } let c = try decoder.container(keyedBy: CodingKeys.self) - try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), operationId: try c.decode(String.self, forKey: .operationId)) + try self.init(version: try c.decode(String.self, forKey: .version), sessionId: try c.decode(String.self, forKey: .sessionId), gatewayId: try c.decode(String.self, forKey: .gatewayId), reconnectSequence: try c.decode(Int64.self, forKey: .reconnectSequence), acquisition: try c.decode(String.self, forKey: .acquisition), operationId: try c.decodeIfPresent(String.self, forKey: .operationId)) } public func validate() throws { @@ -1868,10 +1902,13 @@ public struct GatewayStopWorkRequest: Codable, Equatable { if !self.gatewayId.isEmpty && self.gatewayId.utf8.count < 1 { throw ContractValidationError(field: "gateway_id", code: "min_length") } if self.gatewayId.utf8.count > 128 { throw ContractValidationError(field: "gateway_id", code: "max_length") } if self.reconnectSequence < 0 { throw ContractValidationError(field: "reconnect_sequence", code: "minimum") } - if self.operationId.isEmpty { throw ContractValidationError(field: "operation_id", code: "required") } - if !self.operationId.isEmpty && self.operationId.utf8.count < 36 { throw ContractValidationError(field: "operation_id", code: "min_length") } - if self.operationId.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } - if !validCanonicalUUID(self.operationId) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } + if !["poll", "prompt"].contains(self.acquisition) { throw ContractValidationError(field: "acquisition", code: "invalid_value") } + if let value = self.operationId { + if !value.isEmpty && value.utf8.count < 36 { throw ContractValidationError(field: "operation_id", code: "min_length") } + if value.utf8.count > 36 { throw ContractValidationError(field: "operation_id", code: "max_length") } + if !validCanonicalUUID(value) { throw ContractValidationError(field: "operation_id", code: "invalid_uuid") } + } + if acquisition == "poll" && operationId != nil || acquisition == "prompt" && operationId == nil { throw ContractValidationError(field: "acquisition", code: "invalid_tagged_value") } } public static func decodeJSON(_ data: Data) throws -> Self { try rejectDuplicateJSONKeys(data); return try JSONDecoder().decode(Self.self, from: data) } diff --git a/openapi/control-v1.yaml b/openapi/control-v1.yaml index 3477192..a4973b3 100644 --- a/openapi/control-v1.yaml +++ b/openapi/control-v1.yaml @@ -334,7 +334,7 @@ paths: operationId: getSessionQualityLimits security: [{nativeBearer: []}] responses: - '200': {description: Current global native session quality limits., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/SessionQualityLimits}}}} + '200': {description: 'Current global native session quality limits. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/SessionQualityLimits}}}} '401': {$ref: '#/components/responses/Unauthorized'} /api/v1/session-quality-limits/assignments/{assignment_id}: get: @@ -343,7 +343,7 @@ paths: security: [{nativeBearer: []}] parameters: [{$ref: '#/components/parameters/AssignmentID'}] responses: - '200': {description: Current assignment-specific native session quality limits., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/SessionQualityLimits}}}} + '200': {description: 'Current assignment-specific native session quality limits. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/SessionQualityLimits}}}} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} /api/v1/session-quality-limits/pools/{pool_id}: @@ -353,7 +353,7 @@ paths: security: [{nativeBearer: []}] parameters: [{$ref: '#/components/parameters/PoolID'}] responses: - '200': {description: Current pool-specific native session quality limits., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/SessionQualityLimits}}}} + '200': {description: 'Current pool-specific native session quality limits. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/SessionQualityLimits}}}} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} /api/v1/admin/entitlements/{entitlement_id}/display-limit-override: @@ -365,7 +365,7 @@ paths: browserCsrfCookie: [] browserCsrfHeader: [] parameters: [{$ref: '#/components/parameters/EntitlementID'}] - requestBody: {required: true, content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/DisplayLimitOverrideRequest}}}} + requestBody: {required: true, description: 'Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/DisplayLimitOverrideRequest}}}} responses: '204': {description: Direct entitlement display override updated.} '400': {$ref: '#/components/responses/InvalidRequest'} @@ -377,12 +377,13 @@ paths: post: tags: [session-quality] operationId: createSessionQualityChange + description: The owning principal and active device/key MUST match the subject-scoped broker session. Maximum JSON body: 16384 bytes. security: [{nativeBearer: []}] parameters: [{$ref: '#/components/parameters/SessionID'}, {$ref: '#/components/parameters/IdempotencyKey'}] - requestBody: {required: true, content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeRequest}}}} + requestBody: {required: true, description: 'Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeRequest}}}} responses: - '201': {description: Durable quality change created., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeOperation}}}} - '200': {description: Existing idempotent quality change returned., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeOperation}}}} + '201': {description: 'Durable quality change created. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeOperation}}}} + '200': {description: 'Existing idempotent quality change returned. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeOperation}}}} '400': {$ref: '#/components/responses/InvalidRequest'} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} @@ -391,21 +392,23 @@ paths: get: tags: [session-quality] operationId: getSessionQualityChange + description: The owning principal and active device/key MUST match the subject-scoped broker session and operation. Maximum JSON body: 16384 bytes. security: [{nativeBearer: []}] parameters: [{$ref: '#/components/parameters/SessionID'}, {$ref: '#/components/parameters/OperationID'}] responses: - '200': {description: Durable quality change state., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeOperation}}}} + '200': {description: 'Durable quality change state. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/QualityChangeOperation}}}} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} /api/v1/broker/sessions/{session_id}/stop-operations: post: tags: [session-quality] operationId: createSessionStopOperation + description: The owning principal and active device/key MUST match the subject-scoped broker session. Maximum JSON body: 16384 bytes. security: [{nativeBearer: []}] parameters: [{$ref: '#/components/parameters/SessionID'}, {$ref: '#/components/parameters/IdempotencyKey'}] responses: - '201': {description: Durable Stop operation created., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/StopOperation}}}} - '200': {description: Existing idempotent Stop operation returned., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/StopOperation}}}} + '201': {description: 'Durable Stop operation created. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/StopOperation}}}} + '200': {description: 'Existing idempotent Stop operation returned. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/StopOperation}}}} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} '409': {$ref: '#/components/responses/Conflict'} @@ -413,20 +416,22 @@ paths: get: tags: [session-quality] operationId: getSessionStopOperation + description: The owning principal and active device/key MUST match the subject-scoped broker session and operation. Maximum JSON body: 16384 bytes. security: [{nativeBearer: []}] parameters: [{$ref: '#/components/parameters/SessionID'}, {$ref: '#/components/parameters/OperationID'}] responses: - '200': {description: Durable Stop operation state., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/StopOperation}}}} + '200': {description: 'Durable Stop operation state. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/StopOperation}}}} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/NotFound'} /api/v1/gateway/quality-work: post: tags: [gateway] operationId: acquireGatewayQualityWork + description: The authenticated gateway mTLS certificate identity MUST match `gateway_id`; Server authority MUST match the session, gateway, reconnect sequence, and supplied operation/revision/lease generation coordinates. A `poll` acquisition omits unknown operation coordinates and discovers pending session work after a lost prompt; `prompt` and `observation` coordinates MUST match exactly. Maximum JSON body: 16384 bytes. security: [{gatewayMutualTLS: []}] - requestBody: {required: true, content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayQualityWorkRequest}}}} + requestBody: {required: true, description: 'Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayQualityWorkRequest}}}} responses: - '200': {description: Leased quality work or reconciliation observation., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayQualityWork}}}} + '200': {description: 'Leased quality work or reconciliation observation. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayQualityWork}}}} '204': {description: No matching work is available.} '400': {$ref: '#/components/responses/InvalidRequest'} '401': {$ref: '#/components/responses/Unauthorized'} @@ -435,8 +440,9 @@ 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. security: [{gatewayMutualTLS: []}] - requestBody: {required: true, content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayQualityAck}}}} + requestBody: {required: true, description: 'Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayQualityAck}}}} responses: '204': {description: Quality acknowledgement committed idempotently.} '400': {$ref: '#/components/responses/InvalidRequest'} @@ -446,10 +452,11 @@ paths: post: tags: [gateway] operationId: acquireGatewayStopWork + description: The authenticated gateway mTLS certificate identity MUST match `gateway_id`; Server authority MUST match the session, gateway, reconnect sequence, and any supplied operation coordinate. A `poll` acquisition omits the unknown operation ID and discovers pending session work after a lost prompt; a `prompt` coordinate MUST match exactly. Maximum JSON body: 16384 bytes. security: [{gatewayMutualTLS: []}] - requestBody: {required: true, content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayStopWorkRequest}}}} + requestBody: {required: true, description: 'Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayStopWorkRequest}}}} responses: - '200': {description: One-way non-reissued Stop work claim., content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayStopWork}}}} + '200': {description: 'One-way non-reissued Stop work claim. Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayStopWork}}}} '204': {description: No matching Stop work is available.} '400': {$ref: '#/components/responses/InvalidRequest'} '401': {$ref: '#/components/responses/Unauthorized'} @@ -458,8 +465,9 @@ paths: post: tags: [gateway] operationId: acknowledgeGatewayStopWork + description: The authenticated gateway mTLS certificate identity MUST match `gateway_id`; Server authority MUST exactly match the session, gateway, reconnect sequence, and operation tuple. Maximum JSON body: 16384 bytes. security: [{gatewayMutualTLS: []}] - requestBody: {required: true, content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayStopAck}}}} + requestBody: {required: true, description: 'Maximum JSON body: 16384 bytes.', content: {application/json: {schema: {$ref: ../schemas/control-v1.schema.json#/$defs/GatewayStopAck}}}} responses: '204': {description: Stop acknowledgement committed idempotently.} '400': {$ref: '#/components/responses/InvalidRequest'} @@ -556,7 +564,7 @@ components: name: operation_id in: path required: true - schema: {type: string, format: uuid} + schema: {type: string, format: uuid, pattern: '^(?!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}$'} Limit: name: limit in: query diff --git a/proto/versevdi/tunnel/v1/tunnel.proto b/proto/versevdi/tunnel/v1/tunnel.proto index ec40cc7..185c0bc 100644 --- a/proto/versevdi/tunnel/v1/tunnel.proto +++ b/proto/versevdi/tunnel/v1/tunnel.proto @@ -205,8 +205,10 @@ message GatewayQualityWorkRequest { string gateway_id = 3; uint64 reconnect_sequence = 4; string operation_id = 5; - uint64 revision = 6; + optional uint64 revision = 6; optional uint64 current_applied_revision = 7; + string acquisition = 8; + optional uint64 lease_generation = 9; } message GatewayQualityWork { @@ -219,6 +221,7 @@ message GatewayQualityWork { google.protobuf.Timestamp lease_expires_at = 7; SelectedSessionDescriptor selected_descriptor = 8; optional uint64 current_applied_revision = 9; + uint64 lease_generation = 10; } message GatewayQualityAck { @@ -229,8 +232,9 @@ message GatewayQualityAck { string operation_id = 5; uint64 revision = 6; string outcome = 7; - uint64 current_applied_revision = 8; + optional uint64 current_applied_revision = 8; string failure_code = 9; + uint64 lease_generation = 10; } message GatewayStopWork { @@ -248,6 +252,7 @@ message GatewayStopWorkRequest { string gateway_id = 3; uint64 reconnect_sequence = 4; string operation_id = 5; + string acquisition = 6; } message GatewayStopAck { diff --git a/schemas/control-v1.schema.json b/schemas/control-v1.schema.json index ff991f6..0545c01 100644 --- a/schemas/control-v1.schema.json +++ b/schemas/control-v1.schema.json @@ -269,6 +269,7 @@ }, "SessionQualityLimits": { "type": "object", + "x-max-bytes": 16384, "additionalProperties": false, "required": ["policy_version_id", "policy_display_limit", "selectable_display_maximum", "display_limit_override", "bitrate_minimum_kbps", "bitrate_target_kbps", "bitrate_maximum_kbps"], "properties": { @@ -654,6 +655,7 @@ }, "DisplayLimitOverrideRequest": { "type": "object", + "x-max-bytes": 16384, "additionalProperties": false, "required": ["allow_display_limit_override", "expected_version"], "properties": { @@ -663,6 +665,7 @@ }, "QualityChangeRequest": { "type": "object", + "x-max-bytes": 16384, "additionalProperties": false, "required": ["bitrate_preference", "expected_session_version", "expected_policy_version"], "properties": { @@ -673,10 +676,11 @@ }, "QualityChangeOperation": { "type": "object", + "x-max-bytes": 16384, "additionalProperties": false, "required": ["operation_id", "session_id", "revision", "state", "requested_bitrate_preference", "effective_bitrate_kbps", "governing_policy_version", "session_version", "created_at", "deadline_at", "updated_at"], "properties": { - "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, + "operation_id": {"type": "string", "format": "uuid", "pattern": "^(?!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}$", "minLength": 36, "maxLength": 36}, "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, "revision": {"type": "integer", "minimum": 1}, "state": {"type": "string", "enum": ["pending", "applying", "applied", "failed", "timed_out"]}, @@ -684,84 +688,97 @@ "effective_bitrate_kbps": {"type": "integer", "minimum": 100, "maximum": 1000000}, "governing_policy_version": {"type": "string", "minLength": 1, "maxLength": 128}, "session_version": {"type": "integer", "minimum": 1}, - "created_at": {"type": "string", "format": "date-time", "maxLength": 64}, - "deadline_at": {"type": "string", "format": "date-time", "maxLength": 64}, - "updated_at": {"type": "string", "format": "date-time", "maxLength": 64}, + "created_at": {"type": "string", "format": "date-time", "pattern": "^[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$", "maxLength": 64}, + "deadline_at": {"type": "string", "format": "date-time", "pattern": "^[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$", "maxLength": 64}, + "updated_at": {"type": "string", "format": "date-time", "pattern": "^[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$", "maxLength": 64}, "failure_code": {"type": "string", "minLength": 1, "maxLength": 128} } }, "StopOperation": { "type": "object", + "x-max-bytes": 16384, "additionalProperties": false, "required": ["operation_id", "session_id", "state", "session_version", "created_at", "deadline_at", "updated_at"], "properties": { - "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, + "operation_id": {"type": "string", "format": "uuid", "pattern": "^(?!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}$", "minLength": 36, "maxLength": 36}, "session_id": {"type": "string", "minLength": 1, "maxLength": 128}, "state": {"type": "string", "enum": ["pending", "attempting", "applied", "failed", "termination_unconfirmed"]}, "session_version": {"type": "integer", "minimum": 1}, - "created_at": {"type": "string", "format": "date-time", "maxLength": 64}, - "deadline_at": {"type": "string", "format": "date-time", "maxLength": 64}, - "updated_at": {"type": "string", "format": "date-time", "maxLength": 64}, + "created_at": {"type": "string", "format": "date-time", "pattern": "^[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$", "maxLength": 64}, + "deadline_at": {"type": "string", "format": "date-time", "pattern": "^[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$", "maxLength": 64}, + "updated_at": {"type": "string", "format": "date-time", "pattern": "^[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$", "maxLength": 64}, "failure_code": {"type": "string", "minLength": 1, "maxLength": 128} } }, "GatewayQualityWorkRequest": { "type": "object", "additionalProperties": false, - "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "revision"], + "x-max-bytes": 16384, + "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "acquisition"], "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}, - "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, "revision": {"type": "integer", "minimum": 1}, + "acquisition": {"type": "string", "enum": ["poll", "prompt", "observation"]}, + "operation_id": {"type": "string", "format": "uuid", "pattern": "^(?!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}$", "minLength": 36, "maxLength": 36}, + "revision": {"type": "integer", "minimum": 1, "x-optional-pointer": true}, + "lease_generation": {"type": "integer", "minimum": 1, "x-optional-pointer": true}, "current_applied_revision": {"type": "integer", "minimum": 0, "x-optional-pointer": true} } }, "GatewayQualityWork": { "type": "object", "additionalProperties": false, - "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "revision", "lease_expires_at", "selected_descriptor"], + "x-max-bytes": 16384, + "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "revision", "lease_generation", "lease_expires_at", "selected_descriptor"], "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}, - "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, "revision": {"type": "integer", "minimum": 1}, - "lease_expires_at": {"type": "string", "format": "date-time", "maxLength": 64}, "selected_descriptor": {"$ref": "#/$defs/SelectedSessionDescriptor"}, + "operation_id": {"type": "string", "format": "uuid", "pattern": "^(?!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}$", "minLength": 36, "maxLength": 36}, "revision": {"type": "integer", "minimum": 1}, + "lease_generation": {"type": "integer", "minimum": 1}, + "lease_expires_at": {"type": "string", "format": "date-time", "pattern": "^[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$", "maxLength": 64}, "selected_descriptor": {"$ref": "#/$defs/SelectedSessionDescriptor"}, "current_applied_revision": {"type": "integer", "minimum": 0, "x-optional-pointer": true} } }, "GatewayQualityAck": { "type": "object", "additionalProperties": false, - "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "revision", "outcome", "current_applied_revision"], + "x-max-bytes": 16384, + "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "revision", "lease_generation", "outcome"], "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}, - "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, "revision": {"type": "integer", "minimum": 1}, - "outcome": {"type": "string", "enum": ["applied", "not_applied", "uncertain"]}, "current_applied_revision": {"type": "integer", "minimum": 0}, + "operation_id": {"type": "string", "format": "uuid", "pattern": "^(?!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}$", "minLength": 36, "maxLength": 36}, "revision": {"type": "integer", "minimum": 1}, + "lease_generation": {"type": "integer", "minimum": 1}, + "outcome": {"type": "string", "enum": ["applied", "proven_prior", "unknown"]}, "current_applied_revision": {"type": "integer", "minimum": 0, "x-optional-pointer": true}, "failure_code": {"type": "string", "minLength": 1, "maxLength": 128} } }, "GatewayStopWorkRequest": { "type": "object", "additionalProperties": false, - "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id"], + "x-max-bytes": 16384, + "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "acquisition"], "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}, - "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36} + "acquisition": {"type": "string", "enum": ["poll", "prompt"]}, + "operation_id": {"type": "string", "format": "uuid", "pattern": "^(?!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}$", "minLength": 36, "maxLength": 36} } }, "GatewayStopWork": { "type": "object", "additionalProperties": false, + "x-max-bytes": 16384, "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "attempt"], "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}, - "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, "attempt": {"type": "integer", "minimum": 1, "maximum": 1} + "operation_id": {"type": "string", "format": "uuid", "pattern": "^(?!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}$", "minLength": 36, "maxLength": 36}, "attempt": {"type": "integer", "minimum": 1, "maximum": 1} } }, "GatewayStopAck": { "type": "object", "additionalProperties": false, + "x-max-bytes": 16384, "required": ["version", "session_id", "gateway_id", "reconnect_sequence", "operation_id", "outcome"], "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}, - "operation_id": {"type": "string", "format": "uuid", "minLength": 36, "maxLength": 36}, "outcome": {"type": "string", "enum": ["applied", "failed", "termination_unconfirmed"]}, + "operation_id": {"type": "string", "format": "uuid", "pattern": "^(?!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}$", "minLength": 36, "maxLength": 36}, "outcome": {"type": "string", "enum": ["applied", "failed", "termination_unconfirmed"]}, "failure_code": {"type": "string", "minLength": 1, "maxLength": 128} } }, diff --git a/tests/go/rc6_contract_test.go b/tests/go/rc6_contract_test.go index 3e93a08..95e6b42 100644 --- a/tests/go/rc6_contract_test.go +++ b/tests/go/rc6_contract_test.go @@ -89,11 +89,125 @@ func TestRC6BitratePreferenceTaggedBounds(t *testing.T) { } } +func TestRC6GatewayQualityDiscoveryAndLeaseFencing(t *testing.T) { + operationID := "12345678-1234-1234-1234-123456789abc" + revision := int64(7) + leaseGeneration := int64(3) + currentRevision := int64(6) + + poll := protocol.GatewayQualityWorkRequest{Version: "1", SessionID: "session-1", GatewayID: "gateway-1", ReconnectSequence: 2, Acquisition: "poll"} + if err := poll.Validate(); err != nil { + t.Fatalf("session-bound lost-prompt poll rejected: %v", err) + } + prompt := poll + prompt.Acquisition = "prompt" + prompt.OperationID = operationID + prompt.Revision = &revision + if err := prompt.Validate(); err != nil { + t.Fatalf("prompt-bound acquisition rejected: %v", err) + } + observation := prompt + observation.Acquisition = "observation" + observation.LeaseGeneration = &leaseGeneration + observation.CurrentAppliedRevision = ¤tRevision + if err := observation.Validate(); err != nil { + t.Fatalf("lease-bound applied-revision observation rejected: %v", err) + } + + for name, invalid := range map[string]protocol.GatewayQualityWorkRequest{ + "poll with unknown coordinates": prompt, + "prompt missing revision": {Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Acquisition: "prompt", OperationID: operationID}, + "observation missing lease": {Version: "1", SessionID: "session-1", GatewayID: "gateway-1", Acquisition: "observation", OperationID: operationID, Revision: &revision, CurrentAppliedRevision: ¤tRevision}, + } { + if name == "poll with unknown coordinates" { + invalid.Acquisition = "poll" + } + if invalid.Validate() == nil { + t.Fatalf("accepted invalid %s: %+v", name, invalid) + } + } + + stopPoll := protocol.GatewayStopWorkRequest{Version: "1", SessionID: "session-1", GatewayID: "gateway-1", ReconnectSequence: 2, Acquisition: "poll"} + if err := stopPoll.Validate(); err != nil { + t.Fatalf("session-bound Stop poll rejected: %v", err) + } + stopPrompt := stopPoll + stopPrompt.Acquisition = "prompt" + stopPrompt.OperationID = operationID + if err := stopPrompt.Validate(); err != nil { + t.Fatalf("prompt-bound Stop acquisition rejected: %v", err) + } + stopPoll.OperationID = operationID + if stopPoll.Validate() == nil { + t.Fatal("Stop poll accepted unknown operation coordinates") + } +} + +func TestRC6GatewayQualityAckHasUnambiguousObservation(t *testing.T) { + revision := int64(7) + prior := int64(6) + base := protocol.GatewayQualityAck{ + Version: "1", SessionID: "session-1", GatewayID: "gateway-1", ReconnectSequence: 2, + OperationID: "12345678-1234-1234-1234-123456789abc", Revision: revision, LeaseGeneration: 3, + } + for _, valid := range []protocol.GatewayQualityAck{ + func() protocol.GatewayQualityAck { + value := base + value.Outcome = "applied" + value.CurrentAppliedRevision = &revision + return value + }(), + func() protocol.GatewayQualityAck { + value := base + value.Outcome = "proven_prior" + value.CurrentAppliedRevision = &prior + return value + }(), + func() protocol.GatewayQualityAck { value := base; value.Outcome = "unknown"; return value }(), + } { + if err := valid.Validate(); err != nil { + t.Fatalf("valid quality acknowledgement rejected: %+v: %v", valid, err) + } + } + for _, invalid := range []protocol.GatewayQualityAck{ + func() protocol.GatewayQualityAck { + value := base + value.Outcome = "applied" + value.CurrentAppliedRevision = &prior + return value + }(), + func() protocol.GatewayQualityAck { + value := base + value.Outcome = "proven_prior" + value.CurrentAppliedRevision = &revision + return value + }(), + func() protocol.GatewayQualityAck { + value := base + value.Outcome = "unknown" + value.CurrentAppliedRevision = &prior + return value + }(), + func() protocol.GatewayQualityAck { + value := base + value.Outcome = "unknown" + value.LeaseGeneration = 0 + return value + }(), + } { + if invalid.Validate() == nil { + t.Fatalf("contradictory or unfenced quality acknowledgement accepted: %+v", invalid) + } + } +} + func TestRC6JSONFixturesAndCanonicalOperationFields(t *testing.T) { validFixtures := map[string]func([]byte) error{ "../../fixtures/valid/session-request.json": func(raw []byte) error { _, err := protocol.DecodeSessionRequest(raw); return err }, "../../fixtures/valid/selected-session-descriptor.json": func(raw []byte) error { _, err := protocol.DecodeSelectedSessionDescriptor(raw); return err }, "../../fixtures/valid/session-quality-limits.json": func(raw []byte) error { _, err := protocol.DecodeSessionQualityLimits(raw); return err }, + "../../fixtures/valid/gateway-quality-poll.json": func(raw []byte) error { _, err := protocol.DecodeGatewayQualityWorkRequest(raw); return err }, + "../../fixtures/valid/gateway-quality-ack-applied.json": func(raw []byte) error { _, err := protocol.DecodeGatewayQualityAck(raw); return err }, } for path, decode := range validFixtures { raw, err := os.ReadFile(path) @@ -111,6 +225,12 @@ func TestRC6JSONFixturesAndCanonicalOperationFields(t *testing.T) { "../../fixtures/invalid/video-profile-unknown.json": func(raw []byte) error { _, err := protocol.DecodeVideoProfile(raw); return err }, "../../fixtures/invalid/bitrate-preference-auto-target.json": func(raw []byte) error { _, err := protocol.DecodeBitratePreference(raw); return err }, "../../fixtures/invalid/selected-session-descriptor-provider-field.json": func(raw []byte) error { _, err := protocol.DecodeSelectedSessionDescriptor(raw); return err }, + "../../fixtures/invalid/gateway-quality-poll-coordinates.json": func(raw []byte) error { _, err := protocol.DecodeGatewayQualityWorkRequest(raw); return err }, + "../../fixtures/invalid/gateway-quality-ack-contradictory.json": func(raw []byte) error { _, err := protocol.DecodeGatewayQualityAck(raw); return err }, + "../../fixtures/invalid/gateway-quality-ack-uppercase-uuid.json": func(raw []byte) error { _, err := protocol.DecodeGatewayQualityAck(raw); return err }, + "../../fixtures/invalid/gateway-quality-ack-zero-uuid.json": func(raw []byte) error { _, err := protocol.DecodeGatewayQualityAck(raw); return err }, + "../../fixtures/invalid/quality-operation-offset-time.json": func(raw []byte) error { _, err := protocol.DecodeQualityChangeOperation(raw); return err }, + "../../fixtures/invalid/quality-operation-noncanonical-fraction.json": func(raw []byte) error { _, err := protocol.DecodeQualityChangeOperation(raw); return err }, } for path, decode := range invalidFixtures { raw, err := os.ReadFile(path) diff --git a/tools/generate.py b/tools/generate.py index e2fd48f..c3eb239 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -183,6 +183,16 @@ def go_validation(definition: dict[str, Any]) -> list[str]: lines.append("\tif v.BitrateTargetKbps > v.BitrateMaximumKbps { violations = append(violations, FieldViolation{Field: \"bitrate_bounds\", Code: \"invalid_order\"}) }") if name == "BitratePreference": lines.append("\tif v.Mode == \"auto\" && v.TargetKbps != nil || v.Mode == \"explicit\" && v.TargetKbps == nil { violations = append(violations, FieldViolation{Field: \"target_kbps\", Code: \"invalid_tagged_value\"}) }") + if name == "GatewayQualityWorkRequest": + lines.append("\tif v.Acquisition == \"poll\" && (v.OperationID != \"\" || v.Revision != nil || v.LeaseGeneration != nil || v.CurrentAppliedRevision != nil) { violations = append(violations, FieldViolation{Field: \"acquisition\", Code: \"invalid_tagged_value\"}) }") + lines.append("\tif v.Acquisition == \"prompt\" && (v.OperationID == \"\" || v.Revision == nil || v.LeaseGeneration != nil || v.CurrentAppliedRevision != nil) { violations = append(violations, FieldViolation{Field: \"acquisition\", Code: \"invalid_tagged_value\"}) }") + lines.append("\tif v.Acquisition == \"observation\" && (v.OperationID == \"\" || v.Revision == nil || v.LeaseGeneration == nil || v.CurrentAppliedRevision == nil) { violations = append(violations, FieldViolation{Field: \"acquisition\", Code: \"invalid_tagged_value\"}) }") + if name == "GatewayStopWorkRequest": + lines.append("\tif v.Acquisition == \"poll\" && v.OperationID != \"\" || v.Acquisition == \"prompt\" && v.OperationID == \"\" { violations = append(violations, FieldViolation{Field: \"acquisition\", Code: \"invalid_tagged_value\"}) }") + if name == "GatewayQualityAck": + lines.append("\tif v.Outcome == \"applied\" && (v.CurrentAppliedRevision == nil || *v.CurrentAppliedRevision != v.Revision) { violations = append(violations, FieldViolation{Field: \"current_applied_revision\", Code: \"invalid_tagged_value\"}) }") + lines.append("\tif v.Outcome == \"proven_prior\" && (v.CurrentAppliedRevision == nil || *v.CurrentAppliedRevision >= v.Revision) { violations = append(violations, FieldViolation{Field: \"current_applied_revision\", Code: \"invalid_tagged_value\"}) }") + lines.append("\tif v.Outcome == \"unknown\" && v.CurrentAppliedRevision != nil { violations = append(violations, FieldViolation{Field: \"current_applied_revision\", Code: \"invalid_tagged_value\"}) }") if name == "GatewayRegistration": lines.append("\tif v.ProtocolMinVersion > v.ProtocolMaxVersion { violations = append(violations, FieldViolation{Field: \"protocol_version\", Code: \"invalid_order\"}) }") if name == "ChannelFrame": @@ -471,6 +481,16 @@ def rust_validation(definition: dict[str, Any]) -> list[str]: lines.append(" if self.bitrateTargetKbps > self.bitrateMaximumKbps { return Err(ValidationError::new(\"bitrate_bounds\", \"invalid_order\")); }") if name == "BitratePreference": lines.append(" if self.mode == \"auto\" && self.targetKbps.is_some() || self.mode == \"explicit\" && self.targetKbps.is_none() { return Err(ValidationError::new(\"target_kbps\", \"invalid_tagged_value\")); }") + if name == "GatewayQualityWorkRequest": + lines.append(" if self.acquisition == \"poll\" && (self.operationId.is_some() || self.revision.is_some() || self.leaseGeneration.is_some() || self.currentAppliedRevision.is_some()) { return Err(ValidationError::new(\"acquisition\", \"invalid_tagged_value\")); }") + lines.append(" if self.acquisition == \"prompt\" && (self.operationId.is_none() || self.revision.is_none() || self.leaseGeneration.is_some() || self.currentAppliedRevision.is_some()) { return Err(ValidationError::new(\"acquisition\", \"invalid_tagged_value\")); }") + lines.append(" if self.acquisition == \"observation\" && (self.operationId.is_none() || self.revision.is_none() || self.leaseGeneration.is_none() || self.currentAppliedRevision.is_none()) { return Err(ValidationError::new(\"acquisition\", \"invalid_tagged_value\")); }") + if name == "GatewayStopWorkRequest": + lines.append(" if self.acquisition == \"poll\" && self.operationId.is_some() || self.acquisition == \"prompt\" && self.operationId.is_none() { return Err(ValidationError::new(\"acquisition\", \"invalid_tagged_value\")); }") + if name == "GatewayQualityAck": + lines.append(" if self.outcome == \"applied\" && self.currentAppliedRevision != Some(self.revision) { return Err(ValidationError::new(\"current_applied_revision\", \"invalid_tagged_value\")); }") + lines.append(" if self.outcome == \"proven_prior\" && self.currentAppliedRevision.map_or(true, |current| current >= self.revision) { return Err(ValidationError::new(\"current_applied_revision\", \"invalid_tagged_value\")); }") + lines.append(" if self.outcome == \"unknown\" && self.currentAppliedRevision.is_some() { return Err(ValidationError::new(\"current_applied_revision\", \"invalid_tagged_value\")); }") if name == "GatewayRegistration": lines.append(" if self.protocolMinVersion > self.protocolMaxVersion { return Err(ValidationError::new(\"protocol_version\", \"invalid_order\")); }") if name == "ChannelFrame": @@ -687,6 +707,16 @@ def swift_validation(definition: dict[str, Any]) -> list[str]: lines.append(" if bitrateTargetKbps > bitrateMaximumKbps { throw ContractValidationError(field: \"bitrate_bounds\", code: \"invalid_order\") }") if name == "BitratePreference": lines.append(" if mode == \"auto\" && targetKbps != nil || mode == \"explicit\" && targetKbps == nil { throw ContractValidationError(field: \"target_kbps\", code: \"invalid_tagged_value\") }") + if name == "GatewayQualityWorkRequest": + lines.append(" if acquisition == \"poll\" && (operationId != nil || revision != nil || leaseGeneration != nil || currentAppliedRevision != nil) { throw ContractValidationError(field: \"acquisition\", code: \"invalid_tagged_value\") }") + lines.append(" if acquisition == \"prompt\" && (operationId == nil || revision == nil || leaseGeneration != nil || currentAppliedRevision != nil) { throw ContractValidationError(field: \"acquisition\", code: \"invalid_tagged_value\") }") + lines.append(" if acquisition == \"observation\" && (operationId == nil || revision == nil || leaseGeneration == nil || currentAppliedRevision == nil) { throw ContractValidationError(field: \"acquisition\", code: \"invalid_tagged_value\") }") + if name == "GatewayStopWorkRequest": + lines.append(" if acquisition == \"poll\" && operationId != nil || acquisition == \"prompt\" && operationId == nil { throw ContractValidationError(field: \"acquisition\", code: \"invalid_tagged_value\") }") + if name == "GatewayQualityAck": + lines.append(" if outcome == \"applied\" && currentAppliedRevision != revision { throw ContractValidationError(field: \"current_applied_revision\", code: \"invalid_tagged_value\") }") + lines.append(" if outcome == \"proven_prior\" && (currentAppliedRevision == nil || currentAppliedRevision! >= revision) { throw ContractValidationError(field: \"current_applied_revision\", code: \"invalid_tagged_value\") }") + lines.append(" if outcome == \"unknown\" && currentAppliedRevision != nil { throw ContractValidationError(field: \"current_applied_revision\", code: \"invalid_tagged_value\") }") if name == "GatewayRegistration": lines.append(" if protocolMinVersion > protocolMaxVersion { throw ContractValidationError(field: \"protocol_version\", code: \"invalid_order\") }") if name == "ChannelFrame": @@ -706,6 +736,7 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil "public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }", "private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } }", "private func rejectDuplicateJSONKeys(_ data: Data) throws {", + " guard data.count <= 1_048_576 else { throw ContractValidationError(field: \"json\", code: \"payload_too_large\") }", " var index = 0", " func skipWhitespace() { while index < data.count && [9, 10, 13, 32].contains(data[index]) { index += 1 } }", " func parseString() throws -> String {", @@ -719,7 +750,8 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil " }", " throw ContractValidationError(field: \"json\", code: \"invalid_json\")", " }", - " func parseValue() throws {", + " func parseValue(_ depth: Int) throws {", + " guard depth <= 64 else { throw ContractValidationError(field: \"json\", code: \"nesting_too_deep\") }", " skipWhitespace()", " guard index < data.count else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }", " if data[index] == 123 {", @@ -734,7 +766,7 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil " skipWhitespace()", " guard index < data.count, data[index] == 58 else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }", " index += 1", - " try parseValue()", + " try parseValue(depth + 1)", " skipWhitespace()", " guard index < data.count else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }", " if data[index] == 125 { index += 1; return }", @@ -747,7 +779,7 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil " skipWhitespace()", " if index < data.count, data[index] == 93 { index += 1; return }", " while true {", - " try parseValue()", + " try parseValue(depth + 1)", " skipWhitespace()", " guard index < data.count else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }", " if data[index] == 93 { index += 1; return }", @@ -760,7 +792,7 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil " while index < data.count && ![9, 10, 13, 32, 44, 93, 125].contains(data[index]) { index += 1 }", " guard index > start else { throw ContractValidationError(field: \"json\", code: \"invalid_json\") }", " }", - " try parseValue()", + " try parseValue(0)", " skipWhitespace()", " guard index == data.count else { throw ContractValidationError(field: \"json\", code: \"trailing_json\") }", "}", diff --git a/tools/test_generated_contracts.py b/tools/test_generated_contracts.py index bba4f2d..086cfbb 100644 --- a/tools/test_generated_contracts.py +++ b/tools/test_generated_contracts.py @@ -69,6 +69,21 @@ let audio = try AudioProfile(codec: "opus", sampleRateHz: 48000, channels: 2, ch let display = try DisplayMode(resolutionWidth: 2560, resolutionHeight: 1440, fps: 120) let adjustment = try SessionAdjustment(displayReason: "none", bitrateReason: "none") let descriptor = try SelectedSessionDescriptor(videoProfile: video, audioProfile: audio, displayMode: display, bitrateTargetKbps: 40000, bitrateMaximumKbps: 50000, adjustment: adjustment, mediaTimestampBasis: "gateway-send-wall-clock-ms") +let operationId = "12345678-1234-1234-1234-123456789abc" +_ = try GatewayQualityWorkRequest(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, acquisition: "poll", operationId: nil, revision: nil, leaseGeneration: nil, currentAppliedRevision: nil) +_ = try GatewayQualityWorkRequest(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, acquisition: "prompt", operationId: operationId, revision: 7, leaseGeneration: nil, currentAppliedRevision: nil) +_ = try GatewayQualityWorkRequest(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, acquisition: "observation", operationId: operationId, revision: 7, leaseGeneration: 3, currentAppliedRevision: 6) +do { + _ = try GatewayQualityWorkRequest(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, acquisition: "poll", operationId: operationId, revision: 7, leaseGeneration: nil, currentAppliedRevision: nil) + fatalError("poll accepted unknown operation coordinates") +} catch { } +_ = try GatewayQualityAck(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, operationId: operationId, revision: 7, leaseGeneration: 3, outcome: "applied", currentAppliedRevision: 7, failureCode: nil) +_ = try GatewayQualityAck(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, operationId: operationId, revision: 7, leaseGeneration: 3, outcome: "proven_prior", currentAppliedRevision: 6, failureCode: nil) +_ = try GatewayQualityAck(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, operationId: operationId, revision: 7, leaseGeneration: 3, outcome: "unknown", currentAppliedRevision: nil, failureCode: nil) +do { + _ = try GatewayQualityAck(version: "1", sessionId: "session", gatewayId: "gateway", reconnectSequence: 2, operationId: operationId, revision: 7, leaseGeneration: 3, outcome: "applied", currentAppliedRevision: 6, failureCode: nil) + fatalError("applied ack accepted a contradictory revision") +} catch { } let capability = try CapabilityProfile( transport: "quic-tls13", framing: "datagram-v1", media: "encoded", sourceRateControl: "server", videoProfiles: [video], audioProfiles: [audio] @@ -191,6 +206,19 @@ do { _ = try CapabilityProfile.decodeJSON(duplicateCapability) fatalError("capability accepted duplicate JSON keys") } catch { } +do { + _ = try CapabilityProfile.decodeJSON(Data(repeating: 32, count: 1_048_577)) + fatalError("capability accepted oversized JSON") +} catch let error as ContractValidationError { + guard error.code == "payload_too_large" else { fatalError("oversized JSON was not rejected before parsing") } +} +let deeplyNested = Data((String(repeating: "[", count: 65) + "null" + String(repeating: "]", count: 65)).utf8) +do { + _ = try CapabilityProfile.decodeJSON(deeplyNested) + fatalError("capability accepted over-deep JSON") +} catch let error as ContractValidationError { + guard error.code == "nesting_too_deep" else { fatalError("over-deep JSON was not rejected before decoding") } +} for field in ["version", "session_id", "gateway_id", "audience", "reconnect_sequence", "expires_at", "capabilities", "selected_descriptor"] { var missing = clientAuthorityObject missing.removeValue(forKey: field) @@ -385,6 +413,15 @@ fn main() { let display = DisplayMode::new(2560, 1440, 120).unwrap(); let adjustment = SessionAdjustment::new("none".into(), "none".into()).unwrap(); let descriptor = SelectedSessionDescriptor::new(video.clone(), audio.clone(), display.clone(), 40000, 50000, adjustment, "gateway-send-wall-clock-ms".into()).unwrap(); + let operation_id = "12345678-1234-1234-1234-123456789abc".to_string(); + assert!(GatewayQualityWorkRequest::new("1".into(), "session".into(), "gateway".into(), 2, "poll".into(), None, None, None, None).is_ok()); + assert!(GatewayQualityWorkRequest::new("1".into(), "session".into(), "gateway".into(), 2, "prompt".into(), Some(operation_id.clone()), Some(7), None, None).is_ok()); + assert!(GatewayQualityWorkRequest::new("1".into(), "session".into(), "gateway".into(), 2, "observation".into(), Some(operation_id.clone()), Some(7), Some(3), Some(6)).is_ok()); + assert!(GatewayQualityWorkRequest::new("1".into(), "session".into(), "gateway".into(), 2, "poll".into(), Some(operation_id.clone()), Some(7), None, None).is_err()); + assert!(GatewayQualityAck::new("1".into(), "session".into(), "gateway".into(), 2, operation_id.clone(), 7, 3, "applied".into(), Some(7), None).is_ok()); + assert!(GatewayQualityAck::new("1".into(), "session".into(), "gateway".into(), 2, operation_id.clone(), 7, 3, "proven_prior".into(), Some(6), None).is_ok()); + assert!(GatewayQualityAck::new("1".into(), "session".into(), "gateway".into(), 2, operation_id.clone(), 7, 3, "unknown".into(), None, None).is_ok()); + assert!(GatewayQualityAck::new("1".into(), "session".into(), "gateway".into(), 2, operation_id, 7, 3, "applied".into(), Some(6), None).is_err()); let capabilities = CapabilityProfile::new( "quic-tls13".into(), "datagram-v1".into(), "encoded".into(), "server".into(), vec![video.clone()], vec![audio.clone()], @@ -544,22 +581,68 @@ fn main() { ) run(["rustc", str(rust), "-o", str(workspace / "rust-contracts")], ROOT) run([str(workspace / "rust-contracts")], ROOT) - expected_protobuf_fields = [ - ("version", 1), - ("session_id", 2), - ("gateway_id", 3), - ("audience", 4), - ("reconnect_sequence", 5), - ("expires_at", 6), - ("capabilities", 7), - ("selected_descriptor", 8), - ] - actual_protobuf_fields = protobuf_message_fields("ClientSessionAuthority") - if actual_protobuf_fields != expected_protobuf_fields: - raise RuntimeError( - f"ClientSessionAuthority protobuf fields = {actual_protobuf_fields}; " - f"want {expected_protobuf_fields}" - ) + expected_protobuf_messages = { + "ClientSessionAuthority": [ + ("version", 1), + ("session_id", 2), + ("gateway_id", 3), + ("audience", 4), + ("reconnect_sequence", 5), + ("expires_at", 6), + ("capabilities", 7), + ("selected_descriptor", 8), + ], + "GatewayQualityWorkRequest": [ + ("version", 1), + ("session_id", 2), + ("gateway_id", 3), + ("reconnect_sequence", 4), + ("operation_id", 5), + ("revision", 6), + ("current_applied_revision", 7), + ("acquisition", 8), + ("lease_generation", 9), + ], + "GatewayQualityWork": [ + ("version", 1), + ("session_id", 2), + ("gateway_id", 3), + ("reconnect_sequence", 4), + ("operation_id", 5), + ("revision", 6), + ("lease_expires_at", 7), + ("selected_descriptor", 8), + ("current_applied_revision", 9), + ("lease_generation", 10), + ], + "GatewayQualityAck": [ + ("version", 1), + ("session_id", 2), + ("gateway_id", 3), + ("reconnect_sequence", 4), + ("operation_id", 5), + ("revision", 6), + ("outcome", 7), + ("current_applied_revision", 8), + ("failure_code", 9), + ("lease_generation", 10), + ], + "GatewayStopWorkRequest": [ + ("version", 1), + ("session_id", 2), + ("gateway_id", 3), + ("reconnect_sequence", 4), + ("operation_id", 5), + ("acquisition", 6), + ], + } + for message, expected_protobuf_fields in expected_protobuf_messages.items(): + actual_protobuf_fields = protobuf_message_fields(message) + if actual_protobuf_fields != expected_protobuf_fields: + raise RuntimeError( + f"{message} protobuf fields = {actual_protobuf_fields}; " + f"want {expected_protobuf_fields}" + ) rust_unknown = workspace / "unknown.rs" shutil.copyfile(ROOT / "gen/rust/protocol.rs", rust_unknown) with rust_unknown.open("a", encoding="utf-8") as output: diff --git a/tools/validate.py b/tools/validate.py index 6c1c085..86e6b91 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -53,6 +53,27 @@ def main() -> int: for owner in ("SessionAuthority", "ClientSessionAuthority"): assert defs[owner]["required"][-1] == "selected_descriptor" assert defs["ConnectionManifest"]["required"][-1] == "selected_descriptor" + quality_request = defs["GatewayQualityWorkRequest"] + assert quality_request["required"] == ["version", "session_id", "gateway_id", "reconnect_sequence", "acquisition"] + assert quality_request["properties"]["acquisition"]["enum"] == ["poll", "prompt", "observation"] + 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"] + + 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$" + for definition in ("QualityChangeOperation", "StopOperation", "GatewayQualityWorkRequest", "GatewayQualityWork", "GatewayQualityAck", "GatewayStopWorkRequest", "GatewayStopWork", "GatewayStopAck"): + assert defs[definition]["properties"]["operation_id"]["pattern"] == operation_id_pattern, definition + assert defs[definition]["x-max-bytes"] == 16384, definition + for definition, fields in { + "QualityChangeOperation": ("created_at", "deadline_at", "updated_at"), + "StopOperation": ("created_at", "deadline_at", "updated_at"), + "GatewayQualityWork": ("lease_expires_at",), + }.items(): + for field in fields: + assert defs[definition]["properties"][field]["pattern"] == canonical_time_pattern, (definition, field) display_mode = defs["DisplayMode"] assert display_mode["required"] == ["resolution_width", "resolution_height", "fps"] @@ -207,6 +228,18 @@ def main() -> int: ): operation = openapi.split(f" operationId: {operation_id}\n", 1)[1].split(" responses:\n", 1)[0] assert "gatewayMutualTLS: []" in operation and "nativeBearer" not in operation and "browserSession" not in operation, operation_id + assert "certificate identity MUST match" in operation, operation_id + assert "Maximum JSON body: 16384 bytes." in operation, operation_id + quality_acquisition = openapi.split(" operationId: acquireGatewayQualityWork\n", 1)[1].split(" responses:\n", 1)[0] + assert "`poll` acquisition omits unknown operation coordinates" in quality_acquisition + 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 + 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 + assert openapi.count("Maximum JSON body: 16384 bytes.") >= 8 + assert operation_id_pattern in openapi assert "provider_url" not in openapi and "vm_address" not in openapi session_endpoint = openapi.split(" /api/v1/auth/session:", 1)[1].split("\n /api/", 1)[0] assert "$defs/BrowserAuthenticatedSession" in session_endpoint @@ -238,6 +271,7 @@ def main() -> int: """ admin_override = openapi.split(" operationId: updateEntitlementDisplayLimitOverride\n", 1)[1].split(" responses:\n", 1)[0] assert browser_requirement.removeprefix(" ") in admin_override + assert "Maximum JSON body: 16384 bytes." in admin_override for operation_id in ( "issueReauthenticationGrant", "logoutSession", "registerDevice", "proveDevice", "revokeDevice", "requestBrokerSession", "allocateBrokerSession", "reconnectBrokerSession", "cancelBrokerSession",