diff --git a/api/centralconfig/v1/types.pb.go b/api/centralconfig/v1/types.pb.go
index 598b0600..a0ee7b28 100644
--- a/api/centralconfig/v1/types.pb.go
+++ b/api/centralconfig/v1/types.pb.go
@@ -724,8 +724,14 @@ type Schema struct {
// field; trigger may not appear in its own dependents). Enforced at every
// config write against the post-merge snapshot.
DependentRequired []*DependentRequiredEntry `protobuf:"bytes,12,rep,name=dependent_required,json=dependentRequired,proto3" json:"dependent_required,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ // Cross-field rule expressions reserved for future Common Expression
+ // Language (CEL) evaluation. Stored on the schema and round-tripped
+ // through ImportSchema/GetSchema; the runtime engine ships separately
+ // (see issue #76). Reserving the key in v0.1.0 of the schema spec
+ // avoids a breaking meta-schema change later.
+ Validations []*ValidationRule `protobuf:"bytes,13,rep,name=validations,proto3" json:"validations,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *Schema) Reset() {
@@ -842,6 +848,13 @@ func (x *Schema) GetDependentRequired() []*DependentRequiredEntry {
return nil
}
+func (x *Schema) GetValidations() []*ValidationRule {
+ if x != nil {
+ return x.Validations
+ }
+ return nil
+}
+
// DependentRequiredEntry encodes one cross-field requirement: when the
// trigger field has a non-null value, every dependent field path must also
// have a non-null value. This is the proto wire form of JSON Schema 2020-12
@@ -902,6 +915,104 @@ func (x *DependentRequiredEntry) GetDependentFields() []string {
return nil
}
+// ValidationRule encodes one cross-field rule expressed in Common
+// Expression Language (CEL). Reserved in v0.1.0 of the schema spec — the
+// parser accepts and persists rules, but the engine that compiles and
+// evaluates them ships separately (see issue #76 / .agents/context/cel-validation.md).
+//
+// Rules are scoped to a path prefix: an empty path means a schema-wide
+// rule; a non-empty path anchors the rule to a group for documentation
+// and UI grouping (the binding namespace itself always exposes every
+// field via `self`, regardless of path).
+type ValidationRule struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Optional path prefix scoping the rule to a group of fields. Empty
+ // string means the rule applies at schema scope.
+ Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
+ // The CEL expression source. Lint at ImportSchema in v0.1.0 only checks
+ // that the string is non-empty; CEL compilation happens in Phase 2 once
+ // the engine ships.
+ Rule string `protobuf:"bytes,2,opt,name=rule,proto3" json:"rule,omitempty"`
+ // Human-readable failure message shown to clients when the rule
+ // rejects a write. Required.
+ Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
+ // Optional severity hint. Reserved values: "error" (default — write
+ // rejected) and "warning" (write accepted, surfaced for UI). v0.1.0
+ // only validates the value is empty or one of the reserved set; the
+ // warning path is not yet enforced.
+ Severity string `protobuf:"bytes,4,opt,name=severity,proto3" json:"severity,omitempty"`
+ // Optional machine-readable failure code for SDK consumers that want
+ // to branch on rule outcome without parsing the message text.
+ Reason string `protobuf:"bytes,5,opt,name=reason,proto3" json:"reason,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ValidationRule) Reset() {
+ *x = ValidationRule{}
+ mi := &file_centralconfig_v1_types_proto_msgTypes[8]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ValidationRule) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ValidationRule) ProtoMessage() {}
+
+func (x *ValidationRule) ProtoReflect() protoreflect.Message {
+ mi := &file_centralconfig_v1_types_proto_msgTypes[8]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ValidationRule.ProtoReflect.Descriptor instead.
+func (*ValidationRule) Descriptor() ([]byte, []int) {
+ return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *ValidationRule) GetPath() string {
+ if x != nil {
+ return x.Path
+ }
+ return ""
+}
+
+func (x *ValidationRule) GetRule() string {
+ if x != nil {
+ return x.Rule
+ }
+ return ""
+}
+
+func (x *ValidationRule) GetMessage() string {
+ if x != nil {
+ return x.Message
+ }
+ return ""
+}
+
+func (x *ValidationRule) GetSeverity() string {
+ if x != nil {
+ return x.Severity
+ }
+ return ""
+}
+
+func (x *ValidationRule) GetReason() string {
+ if x != nil {
+ return x.Reason
+ }
+ return ""
+}
+
// Tenant represents an organization or entity that has its own configuration
// based on an assigned schema version.
type Tenant struct {
@@ -926,7 +1037,7 @@ type Tenant struct {
func (x *Tenant) Reset() {
*x = Tenant{}
- mi := &file_centralconfig_v1_types_proto_msgTypes[8]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -938,7 +1049,7 @@ func (x *Tenant) String() string {
func (*Tenant) ProtoMessage() {}
func (x *Tenant) ProtoReflect() protoreflect.Message {
- mi := &file_centralconfig_v1_types_proto_msgTypes[8]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -951,7 +1062,7 @@ func (x *Tenant) ProtoReflect() protoreflect.Message {
// Deprecated: Use Tenant.ProtoReflect.Descriptor instead.
func (*Tenant) Descriptor() ([]byte, []int) {
- return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{8}
+ return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{9}
}
func (x *Tenant) GetId() string {
@@ -1013,7 +1124,7 @@ type FieldLock struct {
func (x *FieldLock) Reset() {
*x = FieldLock{}
- mi := &file_centralconfig_v1_types_proto_msgTypes[9]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1025,7 +1136,7 @@ func (x *FieldLock) String() string {
func (*FieldLock) ProtoMessage() {}
func (x *FieldLock) ProtoReflect() protoreflect.Message {
- mi := &file_centralconfig_v1_types_proto_msgTypes[9]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1038,7 +1149,7 @@ func (x *FieldLock) ProtoReflect() protoreflect.Message {
// Deprecated: Use FieldLock.ProtoReflect.Descriptor instead.
func (*FieldLock) Descriptor() ([]byte, []int) {
- return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{9}
+ return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{10}
}
func (x *FieldLock) GetTenantId() string {
@@ -1083,7 +1194,7 @@ type TypedValue struct {
func (x *TypedValue) Reset() {
*x = TypedValue{}
- mi := &file_centralconfig_v1_types_proto_msgTypes[10]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1095,7 +1206,7 @@ func (x *TypedValue) String() string {
func (*TypedValue) ProtoMessage() {}
func (x *TypedValue) ProtoReflect() protoreflect.Message {
- mi := &file_centralconfig_v1_types_proto_msgTypes[10]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1108,7 +1219,7 @@ func (x *TypedValue) ProtoReflect() protoreflect.Message {
// Deprecated: Use TypedValue.ProtoReflect.Descriptor instead.
func (*TypedValue) Descriptor() ([]byte, []int) {
- return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{10}
+ return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{11}
}
func (x *TypedValue) GetKind() isTypedValue_Kind {
@@ -1269,7 +1380,7 @@ type ConfigValue struct {
func (x *ConfigValue) Reset() {
*x = ConfigValue{}
- mi := &file_centralconfig_v1_types_proto_msgTypes[11]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1281,7 +1392,7 @@ func (x *ConfigValue) String() string {
func (*ConfigValue) ProtoMessage() {}
func (x *ConfigValue) ProtoReflect() protoreflect.Message {
- mi := &file_centralconfig_v1_types_proto_msgTypes[11]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1294,7 +1405,7 @@ func (x *ConfigValue) ProtoReflect() protoreflect.Message {
// Deprecated: Use ConfigValue.ProtoReflect.Descriptor instead.
func (*ConfigValue) Descriptor() ([]byte, []int) {
- return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{11}
+ return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{12}
}
func (x *ConfigValue) GetFieldPath() string {
@@ -1349,7 +1460,7 @@ type ConfigVersion struct {
func (x *ConfigVersion) Reset() {
*x = ConfigVersion{}
- mi := &file_centralconfig_v1_types_proto_msgTypes[12]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1361,7 +1472,7 @@ func (x *ConfigVersion) String() string {
func (*ConfigVersion) ProtoMessage() {}
func (x *ConfigVersion) ProtoReflect() protoreflect.Message {
- mi := &file_centralconfig_v1_types_proto_msgTypes[12]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1374,7 +1485,7 @@ func (x *ConfigVersion) ProtoReflect() protoreflect.Message {
// Deprecated: Use ConfigVersion.ProtoReflect.Descriptor instead.
func (*ConfigVersion) Descriptor() ([]byte, []int) {
- return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{12}
+ return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{13}
}
func (x *ConfigVersion) GetId() string {
@@ -1434,7 +1545,7 @@ type Config struct {
func (x *Config) Reset() {
*x = Config{}
- mi := &file_centralconfig_v1_types_proto_msgTypes[13]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1446,7 +1557,7 @@ func (x *Config) String() string {
func (*Config) ProtoMessage() {}
func (x *Config) ProtoReflect() protoreflect.Message {
- mi := &file_centralconfig_v1_types_proto_msgTypes[13]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1459,7 +1570,7 @@ func (x *Config) ProtoReflect() protoreflect.Message {
// Deprecated: Use Config.ProtoReflect.Descriptor instead.
func (*Config) Descriptor() ([]byte, []int) {
- return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{13}
+ return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{14}
}
func (x *Config) GetTenantId() string {
@@ -1507,7 +1618,7 @@ type ConfigChange struct {
func (x *ConfigChange) Reset() {
*x = ConfigChange{}
- mi := &file_centralconfig_v1_types_proto_msgTypes[14]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1519,7 +1630,7 @@ func (x *ConfigChange) String() string {
func (*ConfigChange) ProtoMessage() {}
func (x *ConfigChange) ProtoReflect() protoreflect.Message {
- mi := &file_centralconfig_v1_types_proto_msgTypes[14]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1532,7 +1643,7 @@ func (x *ConfigChange) ProtoReflect() protoreflect.Message {
// Deprecated: Use ConfigChange.ProtoReflect.Descriptor instead.
func (*ConfigChange) Descriptor() ([]byte, []int) {
- return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{14}
+ return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{15}
}
func (x *ConfigChange) GetTenantId() string {
@@ -1614,7 +1725,7 @@ type AuditEntry struct {
func (x *AuditEntry) Reset() {
*x = AuditEntry{}
- mi := &file_centralconfig_v1_types_proto_msgTypes[15]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1626,7 +1737,7 @@ func (x *AuditEntry) String() string {
func (*AuditEntry) ProtoMessage() {}
func (x *AuditEntry) ProtoReflect() protoreflect.Message {
- mi := &file_centralconfig_v1_types_proto_msgTypes[15]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1639,7 +1750,7 @@ func (x *AuditEntry) ProtoReflect() protoreflect.Message {
// Deprecated: Use AuditEntry.ProtoReflect.Descriptor instead.
func (*AuditEntry) Descriptor() ([]byte, []int) {
- return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{15}
+ return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{16}
}
func (x *AuditEntry) GetId() string {
@@ -1724,7 +1835,7 @@ type UsageStats struct {
func (x *UsageStats) Reset() {
*x = UsageStats{}
- mi := &file_centralconfig_v1_types_proto_msgTypes[16]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -1736,7 +1847,7 @@ func (x *UsageStats) String() string {
func (*UsageStats) ProtoMessage() {}
func (x *UsageStats) ProtoReflect() protoreflect.Message {
- mi := &file_centralconfig_v1_types_proto_msgTypes[16]
+ mi := &file_centralconfig_v1_types_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -1749,7 +1860,7 @@ func (x *UsageStats) ProtoReflect() protoreflect.Message {
// Deprecated: Use UsageStats.ProtoReflect.Descriptor instead.
func (*UsageStats) Descriptor() ([]byte, []int) {
- return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{16}
+ return file_centralconfig_v1_types_proto_rawDescGZIP(), []int{17}
}
func (x *UsageStats) GetTenantId() string {
@@ -1865,7 +1976,7 @@ const file_centralconfig_v1_types_proto_rawDesc = "" +
"\rSchemaContact\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" +
"\x05email\x18\x02 \x01(\tR\x05email\x12\x10\n" +
- "\x03url\x18\x03 \x01(\tR\x03url\"\x8f\x04\n" +
+ "\x03url\x18\x03 \x01(\tR\x03url\"\xd3\x04\n" +
"\x06Schema\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12 \n" +
@@ -1880,11 +1991,18 @@ const file_centralconfig_v1_types_proto_rawDesc = "" +
"created_at\x18\n" +
" \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x120\n" +
"\x04info\x18\v \x01(\v2\x1c.centralconfig.v1.SchemaInfoR\x04info\x12W\n" +
- "\x12dependent_required\x18\f \x03(\v2(.centralconfig.v1.DependentRequiredEntryR\x11dependentRequiredB\x11\n" +
+ "\x12dependent_required\x18\f \x03(\v2(.centralconfig.v1.DependentRequiredEntryR\x11dependentRequired\x12B\n" +
+ "\vvalidations\x18\r \x03(\v2 .centralconfig.v1.ValidationRuleR\vvalidationsB\x11\n" +
"\x0f_parent_version\"h\n" +
"\x16DependentRequiredEntry\x12#\n" +
"\rtrigger_field\x18\x01 \x01(\tR\ftriggerField\x12)\n" +
- "\x10dependent_fields\x18\x02 \x03(\tR\x0fdependentFields\"\xe6\x01\n" +
+ "\x10dependent_fields\x18\x02 \x03(\tR\x0fdependentFields\"\x86\x01\n" +
+ "\x0eValidationRule\x12\x12\n" +
+ "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" +
+ "\x04rule\x18\x02 \x01(\tR\x04rule\x12\x18\n" +
+ "\amessage\x18\x03 \x01(\tR\amessage\x12\x1a\n" +
+ "\bseverity\x18\x04 \x01(\tR\bseverity\x12\x16\n" +
+ "\x06reason\x18\x05 \x01(\tR\x06reason\"\xe6\x01\n" +
"\x06Tenant\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1b\n" +
@@ -2002,7 +2120,7 @@ func file_centralconfig_v1_types_proto_rawDescGZIP() []byte {
}
var file_centralconfig_v1_types_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
-var file_centralconfig_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 19)
+var file_centralconfig_v1_types_proto_msgTypes = make([]protoimpl.MessageInfo, 20)
var file_centralconfig_v1_types_proto_goTypes = []any{
(FieldType)(0), // 0: centralconfig.v1.FieldType
(*FieldConstraints)(nil), // 1: centralconfig.v1.FieldConstraints
@@ -2013,49 +2131,51 @@ var file_centralconfig_v1_types_proto_goTypes = []any{
(*SchemaContact)(nil), // 6: centralconfig.v1.SchemaContact
(*Schema)(nil), // 7: centralconfig.v1.Schema
(*DependentRequiredEntry)(nil), // 8: centralconfig.v1.DependentRequiredEntry
- (*Tenant)(nil), // 9: centralconfig.v1.Tenant
- (*FieldLock)(nil), // 10: centralconfig.v1.FieldLock
- (*TypedValue)(nil), // 11: centralconfig.v1.TypedValue
- (*ConfigValue)(nil), // 12: centralconfig.v1.ConfigValue
- (*ConfigVersion)(nil), // 13: centralconfig.v1.ConfigVersion
- (*Config)(nil), // 14: centralconfig.v1.Config
- (*ConfigChange)(nil), // 15: centralconfig.v1.ConfigChange
- (*AuditEntry)(nil), // 16: centralconfig.v1.AuditEntry
- (*UsageStats)(nil), // 17: centralconfig.v1.UsageStats
- nil, // 18: centralconfig.v1.SchemaField.ExamplesEntry
- nil, // 19: centralconfig.v1.SchemaInfo.LabelsEntry
- (*timestamppb.Timestamp)(nil), // 20: google.protobuf.Timestamp
- (*durationpb.Duration)(nil), // 21: google.protobuf.Duration
+ (*ValidationRule)(nil), // 9: centralconfig.v1.ValidationRule
+ (*Tenant)(nil), // 10: centralconfig.v1.Tenant
+ (*FieldLock)(nil), // 11: centralconfig.v1.FieldLock
+ (*TypedValue)(nil), // 12: centralconfig.v1.TypedValue
+ (*ConfigValue)(nil), // 13: centralconfig.v1.ConfigValue
+ (*ConfigVersion)(nil), // 14: centralconfig.v1.ConfigVersion
+ (*Config)(nil), // 15: centralconfig.v1.Config
+ (*ConfigChange)(nil), // 16: centralconfig.v1.ConfigChange
+ (*AuditEntry)(nil), // 17: centralconfig.v1.AuditEntry
+ (*UsageStats)(nil), // 18: centralconfig.v1.UsageStats
+ nil, // 19: centralconfig.v1.SchemaField.ExamplesEntry
+ nil, // 20: centralconfig.v1.SchemaInfo.LabelsEntry
+ (*timestamppb.Timestamp)(nil), // 21: google.protobuf.Timestamp
+ (*durationpb.Duration)(nil), // 22: google.protobuf.Duration
}
var file_centralconfig_v1_types_proto_depIdxs = []int32{
0, // 0: centralconfig.v1.SchemaField.type:type_name -> centralconfig.v1.FieldType
1, // 1: centralconfig.v1.SchemaField.constraints:type_name -> centralconfig.v1.FieldConstraints
- 18, // 2: centralconfig.v1.SchemaField.examples:type_name -> centralconfig.v1.SchemaField.ExamplesEntry
+ 19, // 2: centralconfig.v1.SchemaField.examples:type_name -> centralconfig.v1.SchemaField.ExamplesEntry
4, // 3: centralconfig.v1.SchemaField.external_docs:type_name -> centralconfig.v1.ExternalDocs
6, // 4: centralconfig.v1.SchemaInfo.contact:type_name -> centralconfig.v1.SchemaContact
- 19, // 5: centralconfig.v1.SchemaInfo.labels:type_name -> centralconfig.v1.SchemaInfo.LabelsEntry
+ 20, // 5: centralconfig.v1.SchemaInfo.labels:type_name -> centralconfig.v1.SchemaInfo.LabelsEntry
2, // 6: centralconfig.v1.Schema.fields:type_name -> centralconfig.v1.SchemaField
- 20, // 7: centralconfig.v1.Schema.created_at:type_name -> google.protobuf.Timestamp
+ 21, // 7: centralconfig.v1.Schema.created_at:type_name -> google.protobuf.Timestamp
5, // 8: centralconfig.v1.Schema.info:type_name -> centralconfig.v1.SchemaInfo
8, // 9: centralconfig.v1.Schema.dependent_required:type_name -> centralconfig.v1.DependentRequiredEntry
- 20, // 10: centralconfig.v1.Tenant.created_at:type_name -> google.protobuf.Timestamp
- 20, // 11: centralconfig.v1.Tenant.updated_at:type_name -> google.protobuf.Timestamp
- 20, // 12: centralconfig.v1.TypedValue.time_value:type_name -> google.protobuf.Timestamp
- 21, // 13: centralconfig.v1.TypedValue.duration_value:type_name -> google.protobuf.Duration
- 11, // 14: centralconfig.v1.ConfigValue.value:type_name -> centralconfig.v1.TypedValue
- 20, // 15: centralconfig.v1.ConfigVersion.created_at:type_name -> google.protobuf.Timestamp
- 12, // 16: centralconfig.v1.Config.values:type_name -> centralconfig.v1.ConfigValue
- 11, // 17: centralconfig.v1.ConfigChange.old_value:type_name -> centralconfig.v1.TypedValue
- 11, // 18: centralconfig.v1.ConfigChange.new_value:type_name -> centralconfig.v1.TypedValue
- 20, // 19: centralconfig.v1.ConfigChange.changed_at:type_name -> google.protobuf.Timestamp
- 20, // 20: centralconfig.v1.AuditEntry.created_at:type_name -> google.protobuf.Timestamp
- 20, // 21: centralconfig.v1.UsageStats.last_read_at:type_name -> google.protobuf.Timestamp
- 3, // 22: centralconfig.v1.SchemaField.ExamplesEntry.value:type_name -> centralconfig.v1.FieldExample
- 23, // [23:23] is the sub-list for method output_type
- 23, // [23:23] is the sub-list for method input_type
- 23, // [23:23] is the sub-list for extension type_name
- 23, // [23:23] is the sub-list for extension extendee
- 0, // [0:23] is the sub-list for field type_name
+ 9, // 10: centralconfig.v1.Schema.validations:type_name -> centralconfig.v1.ValidationRule
+ 21, // 11: centralconfig.v1.Tenant.created_at:type_name -> google.protobuf.Timestamp
+ 21, // 12: centralconfig.v1.Tenant.updated_at:type_name -> google.protobuf.Timestamp
+ 21, // 13: centralconfig.v1.TypedValue.time_value:type_name -> google.protobuf.Timestamp
+ 22, // 14: centralconfig.v1.TypedValue.duration_value:type_name -> google.protobuf.Duration
+ 12, // 15: centralconfig.v1.ConfigValue.value:type_name -> centralconfig.v1.TypedValue
+ 21, // 16: centralconfig.v1.ConfigVersion.created_at:type_name -> google.protobuf.Timestamp
+ 13, // 17: centralconfig.v1.Config.values:type_name -> centralconfig.v1.ConfigValue
+ 12, // 18: centralconfig.v1.ConfigChange.old_value:type_name -> centralconfig.v1.TypedValue
+ 12, // 19: centralconfig.v1.ConfigChange.new_value:type_name -> centralconfig.v1.TypedValue
+ 21, // 20: centralconfig.v1.ConfigChange.changed_at:type_name -> google.protobuf.Timestamp
+ 21, // 21: centralconfig.v1.AuditEntry.created_at:type_name -> google.protobuf.Timestamp
+ 21, // 22: centralconfig.v1.UsageStats.last_read_at:type_name -> google.protobuf.Timestamp
+ 3, // 23: centralconfig.v1.SchemaField.ExamplesEntry.value:type_name -> centralconfig.v1.FieldExample
+ 24, // [24:24] is the sub-list for method output_type
+ 24, // [24:24] is the sub-list for method input_type
+ 24, // [24:24] is the sub-list for extension type_name
+ 24, // [24:24] is the sub-list for extension extendee
+ 0, // [0:24] is the sub-list for field type_name
}
func init() { file_centralconfig_v1_types_proto_init() }
@@ -2066,7 +2186,7 @@ func file_centralconfig_v1_types_proto_init() {
file_centralconfig_v1_types_proto_msgTypes[0].OneofWrappers = []any{}
file_centralconfig_v1_types_proto_msgTypes[1].OneofWrappers = []any{}
file_centralconfig_v1_types_proto_msgTypes[6].OneofWrappers = []any{}
- file_centralconfig_v1_types_proto_msgTypes[10].OneofWrappers = []any{
+ file_centralconfig_v1_types_proto_msgTypes[11].OneofWrappers = []any{
(*TypedValue_IntegerValue)(nil),
(*TypedValue_NumberValue)(nil),
(*TypedValue_StringValue)(nil),
@@ -2076,16 +2196,16 @@ func file_centralconfig_v1_types_proto_init() {
(*TypedValue_UrlValue)(nil),
(*TypedValue_JsonValue)(nil),
}
- file_centralconfig_v1_types_proto_msgTypes[11].OneofWrappers = []any{}
- file_centralconfig_v1_types_proto_msgTypes[15].OneofWrappers = []any{}
+ file_centralconfig_v1_types_proto_msgTypes[12].OneofWrappers = []any{}
file_centralconfig_v1_types_proto_msgTypes[16].OneofWrappers = []any{}
+ file_centralconfig_v1_types_proto_msgTypes[17].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_centralconfig_v1_types_proto_rawDesc), len(file_centralconfig_v1_types_proto_rawDesc)),
NumEnums: 1,
- NumMessages: 19,
+ NumMessages: 20,
NumExtensions: 0,
NumServices: 0,
},
diff --git a/cmd/server/openapi.json b/cmd/server/openapi.json
index 8990bfab..64af8873 100644
--- a/cmd/server/openapi.json
+++ b/cmd/server/openapi.json
@@ -2230,6 +2230,14 @@
"$ref": "#/definitions/v1DependentRequiredEntry"
},
"description": "Cross-field \"B required when A present\" rules. Each entry declares one\ntrigger field whose presence (non-null value) makes a list of dependent\nfield paths required (also non-null). Equivalent to JSON Schema 2020-12\ndependentRequired, scoped to schema-level cross-field requirement.\nLint-checked at ImportSchema time (every path must reference a real\nfield; trigger may not appear in its own dependents). Enforced at every\nconfig write against the post-merge snapshot."
+ },
+ "validations": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "$ref": "#/definitions/v1ValidationRule"
+ },
+ "description": "Cross-field rule expressions reserved for future Common Expression\nLanguage (CEL) evaluation. Stored on the schema and round-tripped\nthrough ImportSchema/GetSchema; the runtime engine ships separately\n(see issue #76). Reserving the key in v0.1.0 of the schema spec\navoids a breaking meta-schema change later."
}
},
"description": "Schema represents a configuration schema template.\nSchemas define the allowed fields and their types for tenant configurations.\nEach schema is versioned \u2014 updates create new immutable versions."
@@ -2505,6 +2513,32 @@
}
},
"description": "UsageStats represents aggregated read usage statistics for a config field."
+ },
+ "v1ValidationRule": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Optional path prefix scoping the rule to a group of fields. Empty\nstring means the rule applies at schema scope."
+ },
+ "rule": {
+ "type": "string",
+ "description": "The CEL expression source. Lint at ImportSchema in v0.1.0 only checks\nthat the string is non-empty; CEL compilation happens in Phase 2 once\nthe engine ships."
+ },
+ "message": {
+ "type": "string",
+ "description": "Human-readable failure message shown to clients when the rule\nrejects a write. Required."
+ },
+ "severity": {
+ "type": "string",
+ "description": "Optional severity hint. Reserved values: \"error\" (default \u2014 write\nrejected) and \"warning\" (write accepted, surfaced for UI). v0.1.0\nonly validates the value is empty or one of the reserved set; the\nwarning path is not yet enforced."
+ },
+ "reason": {
+ "type": "string",
+ "description": "Optional machine-readable failure code for SDK consumers that want\nto branch on rule outcome without parsing the message text."
+ }
+ },
+ "description": "ValidationRule encodes one cross-field rule expressed in Common\nExpression Language (CEL). Reserved in v0.1.0 of the schema spec \u2014 the\nparser accepts and persists rules, but the engine that compiles and\nevaluates them ships separately (see issue #76 / .agents/context/cel-validation.md).\n\nRules are scoped to a path prefix: an empty path means a schema-wide\nrule; a non-empty path anchors the rule to a group for documentation\nand UI grouping (the binding namespace itself always exposes every\nfield via `self`, regardless of path)."
}
},
"securityDefinitions": {
diff --git a/db/migrations/001_initial_schema.sql b/db/migrations/001_initial_schema.sql
index ac20aaa0..ff24af0d 100644
--- a/db/migrations/001_initial_schema.sql
+++ b/db/migrations/001_initial_schema.sql
@@ -34,6 +34,10 @@ CREATE TABLE schema_versions (
-- JSON array of {trigger_field, dependent_fields} entries encoding the
-- schema's dependentRequired rules. Empty array when no rules exist.
dependent_required JSONB NOT NULL DEFAULT '[]',
+ -- JSON array of {path, rule, message, severity?, reason?} entries
+ -- encoding the schema's CEL validation rules. Reserved in v0.1.0 —
+ -- parser persists; runtime engine ships in Phase 2 (see issue #76).
+ validations JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(schema_id, version)
);
diff --git a/db/queries/schemas.sql b/db/queries/schemas.sql
index 24cace0e..6cf77ec9 100644
--- a/db/queries/schemas.sql
+++ b/db/queries/schemas.sql
@@ -18,8 +18,8 @@ LIMIT $1 OFFSET $2;
DELETE FROM schemas WHERE id = $1;
-- name: CreateSchemaVersion :one
-INSERT INTO schema_versions (schema_id, version, parent_version, description, checksum, dependent_required)
-VALUES ($1, $2, $3, $4, $5, $6)
+INSERT INTO schema_versions (schema_id, version, parent_version, description, checksum, dependent_required, validations)
+VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *;
-- name: GetSchemaVersion :one
diff --git a/docs/api/api-reference.md b/docs/api/api-reference.md
index 7466ecf4..a5881678 100644
--- a/docs/api/api-reference.md
+++ b/docs/api/api-reference.md
@@ -23,6 +23,7 @@
- [Tenant](#centralconfig-v1-Tenant)
- [TypedValue](#centralconfig-v1-TypedValue)
- [UsageStats](#centralconfig-v1-UsageStats)
+ - [ValidationRule](#centralconfig-v1-ValidationRule)
- [FieldType](#centralconfig-v1-FieldType)
@@ -344,6 +345,7 @@ Each schema is versioned — updates create new immutable versions.
| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | When this version was created. |
| info | [SchemaInfo](#centralconfig-v1-SchemaInfo) | | Optional schema metadata: ownership, contact, labels. |
| dependent_required | [DependentRequiredEntry](#centralconfig-v1-DependentRequiredEntry) | repeated | Cross-field "B required when A present" rules. Each entry declares one trigger field whose presence (non-null value) makes a list of dependent field paths required (also non-null). Equivalent to JSON Schema 2020-12 dependentRequired, scoped to schema-level cross-field requirement. Lint-checked at ImportSchema time (every path must reference a real field; trigger may not appear in its own dependents). Enforced at every config write against the post-merge snapshot. |
+| validations | [ValidationRule](#centralconfig-v1-ValidationRule) | repeated | Cross-field rule expressions reserved for future Common Expression Language (CEL) evaluation. Stored on the schema and round-tripped through ImportSchema/GetSchema; the runtime engine ships separately (see issue #76). Reserving the key in v0.1.0 of the schema spec avoids a breaking meta-schema change later. |
@@ -515,6 +517,33 @@ UsageStats represents aggregated read usage statistics for a config field.
+
+
+
+### ValidationRule
+ValidationRule encodes one cross-field rule expressed in Common
+Expression Language (CEL). Reserved in v0.1.0 of the schema spec — the
+parser accepts and persists rules, but the engine that compiles and
+evaluates them ships separately (see issue #76 / .agents/context/cel-validation.md).
+
+Rules are scoped to a path prefix: an empty path means a schema-wide
+rule; a non-empty path anchors the rule to a group for documentation
+and UI grouping (the binding namespace itself always exposes every
+field via `self`, regardless of path).
+
+
+| Field | Type | Label | Description |
+| ----- | ---- | ----- | ----------- |
+| path | [string](#string) | | Optional path prefix scoping the rule to a group of fields. Empty string means the rule applies at schema scope. |
+| rule | [string](#string) | | The CEL expression source. Lint at ImportSchema in v0.1.0 only checks that the string is non-empty; CEL compilation happens in Phase 2 once the engine ships. |
+| message | [string](#string) | | Human-readable failure message shown to clients when the rule rejects a write. Required. |
+| severity | [string](#string) | | Optional severity hint. Reserved values: "error" (default — write rejected) and "warning" (write accepted, surfaced for UI). v0.1.0 only validates the value is empty or one of the reserved set; the warning path is not yet enforced. |
+| reason | [string](#string) | | Optional machine-readable failure code for SDK consumers that want to branch on rule outcome without parsing the message text. |
+
+
+
+
+
diff --git a/docs/api/openapi.swagger.json b/docs/api/openapi.swagger.json
index 8990bfab..64af8873 100644
--- a/docs/api/openapi.swagger.json
+++ b/docs/api/openapi.swagger.json
@@ -2230,6 +2230,14 @@
"$ref": "#/definitions/v1DependentRequiredEntry"
},
"description": "Cross-field \"B required when A present\" rules. Each entry declares one\ntrigger field whose presence (non-null value) makes a list of dependent\nfield paths required (also non-null). Equivalent to JSON Schema 2020-12\ndependentRequired, scoped to schema-level cross-field requirement.\nLint-checked at ImportSchema time (every path must reference a real\nfield; trigger may not appear in its own dependents). Enforced at every\nconfig write against the post-merge snapshot."
+ },
+ "validations": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "$ref": "#/definitions/v1ValidationRule"
+ },
+ "description": "Cross-field rule expressions reserved for future Common Expression\nLanguage (CEL) evaluation. Stored on the schema and round-tripped\nthrough ImportSchema/GetSchema; the runtime engine ships separately\n(see issue #76). Reserving the key in v0.1.0 of the schema spec\navoids a breaking meta-schema change later."
}
},
"description": "Schema represents a configuration schema template.\nSchemas define the allowed fields and their types for tenant configurations.\nEach schema is versioned \u2014 updates create new immutable versions."
@@ -2505,6 +2513,32 @@
}
},
"description": "UsageStats represents aggregated read usage statistics for a config field."
+ },
+ "v1ValidationRule": {
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Optional path prefix scoping the rule to a group of fields. Empty\nstring means the rule applies at schema scope."
+ },
+ "rule": {
+ "type": "string",
+ "description": "The CEL expression source. Lint at ImportSchema in v0.1.0 only checks\nthat the string is non-empty; CEL compilation happens in Phase 2 once\nthe engine ships."
+ },
+ "message": {
+ "type": "string",
+ "description": "Human-readable failure message shown to clients when the rule\nrejects a write. Required."
+ },
+ "severity": {
+ "type": "string",
+ "description": "Optional severity hint. Reserved values: \"error\" (default \u2014 write\nrejected) and \"warning\" (write accepted, surfaced for UI). v0.1.0\nonly validates the value is empty or one of the reserved set; the\nwarning path is not yet enforced."
+ },
+ "reason": {
+ "type": "string",
+ "description": "Optional machine-readable failure code for SDK consumers that want\nto branch on rule outcome without parsing the message text."
+ }
+ },
+ "description": "ValidationRule encodes one cross-field rule expressed in Common\nExpression Language (CEL). Reserved in v0.1.0 of the schema spec \u2014 the\nparser accepts and persists rules, but the engine that compiles and\nevaluates them ships separately (see issue #76 / .agents/context/cel-validation.md).\n\nRules are scoped to a path prefix: an empty path means a schema-wide\nrule; a non-empty path anchors the rule to a group for documentation\nand UI grouping (the binding namespace itself always exposes every\nfield via `self`, regardless of path)."
}
},
"securityDefinitions": {
diff --git a/internal/schema/convert.go b/internal/schema/convert.go
index 4a354b44..51a57cf7 100644
--- a/internal/schema/convert.go
+++ b/internal/schema/convert.go
@@ -41,6 +41,9 @@ func schemaToProto(s domain.Schema, v domain.SchemaVersion, fields []domain.Sche
if entries := UnmarshalDependentRequired(v.DependentRequired); len(entries) > 0 {
result.DependentRequired = entries
}
+ if rules := UnmarshalValidations(v.Validations); len(rules) > 0 {
+ result.Validations = rules
+ }
return result
}
diff --git a/internal/schema/service.go b/internal/schema/service.go
index 83a9c82b..81cd162a 100644
--- a/internal/schema/service.go
+++ b/internal/schema/service.go
@@ -616,6 +616,11 @@ func (s *Service) ImportSchema(ctx context.Context, req *pb.ImportSchemaRequest)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to encode dependentRequired: %v", err)
}
+ validations := yamlToProtoValidations(doc.Validations)
+ validationsJSON, err := MarshalValidations(validations)
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to encode validations: %v", err)
+ }
checksum := computeChecksum(fields)
// Check if schema already exists by name.
@@ -626,7 +631,7 @@ func (s *Service) ImportSchema(ctx context.Context, req *pb.ImportSchemaRequest)
if errors.Is(err, domain.ErrNotFound) {
// New schema — create with v1.
- resp, err := s.importCreateNew(ctx, doc, fields, checksum, depReqJSON)
+ resp, err := s.importCreateNew(ctx, doc, fields, checksum, depReqJSON, validationsJSON)
if err != nil || !req.AutoPublish {
return resp, err
}
@@ -651,14 +656,14 @@ func (s *Service) ImportSchema(ctx context.Context, req *pb.ImportSchemaRequest)
}
// Create new version.
- resp, err := s.importNewVersion(ctx, existing, latestVersion, doc, fields, checksum, depReqJSON)
+ resp, err := s.importNewVersion(ctx, existing, latestVersion, doc, fields, checksum, depReqJSON, validationsJSON)
if err != nil || !req.AutoPublish {
return resp, err
}
return s.autoPublish(ctx, resp)
}
-func (s *Service) importCreateNew(ctx context.Context, doc *SchemaYAML, fields []*pb.SchemaField, checksum string, depReqJSON []byte) (*pb.ImportSchemaResponse, error) {
+func (s *Service) importCreateNew(ctx context.Context, doc *SchemaYAML, fields []*pb.SchemaField, checksum string, depReqJSON, validationsJSON []byte) (*pb.ImportSchemaResponse, error) {
schema, err := s.store.CreateSchema(ctx, CreateSchemaParams{
Name: doc.Name,
Description: ptrString(doc.Description),
@@ -674,6 +679,7 @@ func (s *Service) importCreateNew(ctx context.Context, doc *SchemaYAML, fields [
Description: ptrString(doc.VersionDescription),
Checksum: checksum,
DependentRequired: depReqJSON,
+ Validations: validationsJSON,
})
if err != nil {
s.logger.ErrorContext(ctx, "import: create version", "error", err)
@@ -690,7 +696,7 @@ func (s *Service) importCreateNew(ctx context.Context, doc *SchemaYAML, fields [
}, nil
}
-func (s *Service) importNewVersion(ctx context.Context, schema domain.Schema, latestVersion domain.SchemaVersion, doc *SchemaYAML, fields []*pb.SchemaField, checksum string, depReqJSON []byte) (*pb.ImportSchemaResponse, error) {
+func (s *Service) importNewVersion(ctx context.Context, schema domain.Schema, latestVersion domain.SchemaVersion, doc *SchemaYAML, fields []*pb.SchemaField, checksum string, depReqJSON, validationsJSON []byte) (*pb.ImportSchemaResponse, error) {
newVersion, err := s.store.CreateSchemaVersion(ctx, CreateSchemaVersionParams{
SchemaID: schema.ID,
Version: latestVersion.Version + 1,
@@ -698,6 +704,7 @@ func (s *Service) importNewVersion(ctx context.Context, schema domain.Schema, la
Description: ptrString(doc.VersionDescription),
Checksum: checksum,
DependentRequired: depReqJSON,
+ Validations: validationsJSON,
})
if err != nil {
s.logger.ErrorContext(ctx, "import: create new version", "error", err)
diff --git a/internal/schema/store.go b/internal/schema/store.go
index d464d1b5..014c53c6 100644
--- a/internal/schema/store.go
+++ b/internal/schema/store.go
@@ -23,6 +23,9 @@ type CreateSchemaVersionParams struct {
// Pass an empty []byte (or nil) when no rules exist; the store will
// persist `[]` so reads always return well-formed JSON.
DependentRequired []byte
+ // Validations is the JSON-encoded list of CEL validation rules
+ // reserved in v0.1.0. Same nil-safe semantics as DependentRequired.
+ Validations []byte
}
// GetSchemaVersionParams identifies a specific schema version.
diff --git a/internal/schema/store_memory.go b/internal/schema/store_memory.go
index 8fdb5608..dbe1ded4 100644
--- a/internal/schema/store_memory.go
+++ b/internal/schema/store_memory.go
@@ -145,6 +145,10 @@ func (m *MemoryStore) CreateSchemaVersion(_ context.Context, arg CreateSchemaVer
if len(depReq) == 0 {
depReq = []byte("[]")
}
+ validations := arg.Validations
+ if len(validations) == 0 {
+ validations = []byte("[]")
+ }
sv := domain.SchemaVersion{
ID: m.nextID(),
SchemaID: arg.SchemaID,
@@ -154,6 +158,7 @@ func (m *MemoryStore) CreateSchemaVersion(_ context.Context, arg CreateSchemaVer
Checksum: arg.Checksum,
Published: false,
DependentRequired: depReq,
+ Validations: validations,
CreatedAt: time.Now(),
}
m.schemaVersions[sv.ID] = sv
diff --git a/internal/schema/store_pg.go b/internal/schema/store_pg.go
index 73d59695..43d5ed56 100644
--- a/internal/schema/store_pg.go
+++ b/internal/schema/store_pg.go
@@ -92,6 +92,10 @@ func (s *PGStore) CreateSchemaVersion(ctx context.Context, arg CreateSchemaVersi
if len(depReq) == 0 {
depReq = []byte("[]")
}
+ validations := arg.Validations
+ if len(validations) == 0 {
+ validations = []byte("[]")
+ }
row, err := s.write.CreateSchemaVersion(ctx, dbstore.CreateSchemaVersionParams{
SchemaID: schemaID,
Version: arg.Version,
@@ -99,6 +103,7 @@ func (s *PGStore) CreateSchemaVersion(ctx context.Context, arg CreateSchemaVersi
Description: arg.Description,
Checksum: arg.Checksum,
DependentRequired: depReq,
+ Validations: validations,
})
if err != nil {
return domain.SchemaVersion{}, err
@@ -409,6 +414,7 @@ func schemaVersionFromDB(r dbstore.SchemaVersion) domain.SchemaVersion {
Checksum: r.Checksum,
Published: r.Published,
DependentRequired: r.DependentRequired,
+ Validations: r.Validations,
CreatedAt: pgconv.TimestamptzToTime(r.CreatedAt),
}
}
diff --git a/internal/schema/validations.go b/internal/schema/validations.go
new file mode 100644
index 00000000..64e5d9be
--- /dev/null
+++ b/internal/schema/validations.go
@@ -0,0 +1,65 @@
+package schema
+
+import (
+ "encoding/json"
+
+ pb "github.com/opendecree/decree/api/centralconfig/v1"
+)
+
+// validationWireEntry is the JSON shape stored in the
+// schema_versions.validations column. Field names match the proto wire
+// names (snake_case) so an external tool that reads the column directly
+// sees the same shape it would over the gRPC API.
+type validationWireEntry struct {
+ Path string `json:"path,omitempty"`
+ Rule string `json:"rule"`
+ Message string `json:"message"`
+ Severity string `json:"severity,omitempty"`
+ Reason string `json:"reason,omitempty"`
+}
+
+// MarshalValidations encodes proto ValidationRule entries as the JSON
+// array stored in the schema_versions.validations column. Always returns
+// valid JSON — `[]` for empty input — so the column never holds NULL or
+// junk.
+func MarshalValidations(entries []*pb.ValidationRule) ([]byte, error) {
+ if len(entries) == 0 {
+ return []byte("[]"), nil
+ }
+ wire := make([]validationWireEntry, 0, len(entries))
+ for _, e := range entries {
+ wire = append(wire, validationWireEntry{
+ Path: e.Path,
+ Rule: e.Rule,
+ Message: e.Message,
+ Severity: e.Severity,
+ Reason: e.Reason,
+ })
+ }
+ return json.Marshal(wire)
+}
+
+// UnmarshalValidations decodes the JSON-stored rules back into proto
+// entries. Returns nil for empty / `[]` / unparseable input — callers
+// should treat that as "no rules". Exported so tooling can decode the
+// column without re-inventing the wire format.
+func UnmarshalValidations(raw []byte) []*pb.ValidationRule {
+ if len(raw) == 0 {
+ return nil
+ }
+ var wire []validationWireEntry
+ if err := json.Unmarshal(raw, &wire); err != nil || len(wire) == 0 {
+ return nil
+ }
+ out := make([]*pb.ValidationRule, 0, len(wire))
+ for _, w := range wire {
+ out = append(out, &pb.ValidationRule{
+ Path: w.Path,
+ Rule: w.Rule,
+ Message: w.Message,
+ Severity: w.Severity,
+ Reason: w.Reason,
+ })
+ }
+ return out
+}
diff --git a/internal/schema/validations_test.go b/internal/schema/validations_test.go
new file mode 100644
index 00000000..0e364fab
--- /dev/null
+++ b/internal/schema/validations_test.go
@@ -0,0 +1,230 @@
+package schema
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ pb "github.com/opendecree/decree/api/centralconfig/v1"
+)
+
+// --- validateValidationsYAML (structural lint) ---
+
+func TestValidateValidationsYAML_Empty(t *testing.T) {
+ require.NoError(t, validateValidationsYAML(&SchemaYAML{}))
+}
+
+func TestValidateValidationsYAML_ValidMinimal(t *testing.T) {
+ doc := &SchemaYAML{
+ Validations: []ValidationYAML{
+ {Rule: "self.a > 0", Message: "a must be positive"},
+ },
+ }
+ require.NoError(t, validateValidationsYAML(doc))
+}
+
+func TestValidateValidationsYAML_ValidFull(t *testing.T) {
+ doc := &SchemaYAML{
+ Validations: []ValidationYAML{
+ {Path: "payments", Rule: "self.a > 0", Message: "msg", Severity: "error", Reason: "POSITIVE_REQUIRED"},
+ {Path: "billing", Rule: "self.b < 100", Message: "msg2", Severity: "warning"},
+ },
+ }
+ require.NoError(t, validateValidationsYAML(doc))
+}
+
+func TestValidateValidationsYAML_RejectsEmptyRule(t *testing.T) {
+ doc := &SchemaYAML{
+ Validations: []ValidationYAML{
+ {Rule: "", Message: "x"},
+ },
+ }
+ err := validateValidationsYAML(doc)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "rule is required")
+}
+
+func TestValidateValidationsYAML_RejectsEmptyMessage(t *testing.T) {
+ doc := &SchemaYAML{
+ Validations: []ValidationYAML{
+ {Rule: "self.a > 0", Message: ""},
+ },
+ }
+ err := validateValidationsYAML(doc)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "message is required")
+}
+
+func TestValidateValidationsYAML_RejectsBadSeverity(t *testing.T) {
+ doc := &SchemaYAML{
+ Validations: []ValidationYAML{
+ {Rule: "self.a > 0", Message: "msg", Severity: "panic"},
+ },
+ }
+ err := validateValidationsYAML(doc)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), `"panic"`)
+ assert.Contains(t, err.Error(), `"error" or "warning"`)
+}
+
+func TestValidateValidationsYAML_RejectsUnknownExtension(t *testing.T) {
+ doc := &SchemaYAML{
+ Validations: []ValidationYAML{
+ {
+ Rule: "self.a > 0",
+ Message: "msg",
+ Extensions: map[string]any{"unknown": "x"},
+ },
+ },
+ }
+ err := validateValidationsYAML(doc)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "unknown")
+}
+
+func TestValidateValidationsYAML_AcceptsXExtension(t *testing.T) {
+ doc := &SchemaYAML{
+ Validations: []ValidationYAML{
+ {
+ Rule: "self.a > 0",
+ Message: "msg",
+ Extensions: map[string]any{"x-vendor-id": "abc"},
+ },
+ },
+ }
+ require.NoError(t, validateValidationsYAML(doc))
+}
+
+func TestValidateValidationsYAML_ErrorIncludesIndex(t *testing.T) {
+ doc := &SchemaYAML{
+ Validations: []ValidationYAML{
+ {Rule: "self.a > 0", Message: "ok"},
+ {Rule: "", Message: "msg"}, // index 1 is bad
+ },
+ }
+ err := validateValidationsYAML(doc)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "validations[1]")
+}
+
+// --- Marshal / Unmarshal round-trip ---
+
+func TestMarshalValidations_EmptyReturnsBracketArray(t *testing.T) {
+ raw, err := MarshalValidations(nil)
+ require.NoError(t, err)
+ assert.Equal(t, "[]", string(raw))
+}
+
+func TestMarshalUnmarshalValidations_RoundTrip(t *testing.T) {
+ in := []*pb.ValidationRule{
+ {Path: "p", Rule: "self.a > 0", Message: "m1", Severity: "error", Reason: "R1"},
+ {Rule: "self.b < 100", Message: "m2"},
+ }
+ raw, err := MarshalValidations(in)
+ require.NoError(t, err)
+ require.NotEmpty(t, raw)
+
+ out := UnmarshalValidations(raw)
+ require.Len(t, out, 2)
+ assert.Equal(t, "p", out[0].Path)
+ assert.Equal(t, "self.a > 0", out[0].Rule)
+ assert.Equal(t, "m1", out[0].Message)
+ assert.Equal(t, "error", out[0].Severity)
+ assert.Equal(t, "R1", out[0].Reason)
+ assert.Equal(t, "", out[1].Path)
+ assert.Equal(t, "self.b < 100", out[1].Rule)
+}
+
+func TestUnmarshalValidations_EmptyInputReturnsNil(t *testing.T) {
+ assert.Nil(t, UnmarshalValidations(nil))
+ assert.Nil(t, UnmarshalValidations([]byte("[]")))
+}
+
+func TestUnmarshalValidations_MalformedReturnsNil(t *testing.T) {
+ assert.Nil(t, UnmarshalValidations([]byte("not json")))
+}
+
+// --- proto <-> YAML conversion helpers ---
+
+func TestYamlToProtoValidations_EmptyReturnsNil(t *testing.T) {
+ assert.Nil(t, yamlToProtoValidations(nil))
+ assert.Nil(t, yamlToProtoValidations([]ValidationYAML{}))
+}
+
+func TestProtoValidationsToYAML_EmptyReturnsNil(t *testing.T) {
+ assert.Nil(t, protoValidationsToYAML(nil))
+ assert.Nil(t, protoValidationsToYAML([]*pb.ValidationRule{}))
+}
+
+func TestProtoValidationsToYAML_PreservesOrder(t *testing.T) {
+ in := []*pb.ValidationRule{
+ {Path: "z", Rule: "rZ", Message: "mZ"},
+ {Path: "a", Rule: "rA", Message: "mA"},
+ }
+ out := protoValidationsToYAML(in)
+ require.Len(t, out, 2)
+ // Order preserved — schema authors expect this.
+ assert.Equal(t, "z", out[0].Path)
+ assert.Equal(t, "a", out[1].Path)
+}
+
+func TestValidationsRoundTrip_YAMLToProtoToYAML(t *testing.T) {
+ yamlIn := []ValidationYAML{
+ {Path: "p", Rule: "self.a > 0", Message: "m1", Severity: "warning", Reason: "R"},
+ }
+ proto := yamlToProtoValidations(yamlIn)
+ yamlOut := protoValidationsToYAML(proto)
+ require.Len(t, yamlOut, 1)
+ assert.Equal(t, yamlIn[0], yamlOut[0])
+}
+
+// --- Top-level YAML parser integration ---
+
+func TestUnmarshalSchemaYAML_Validations_Valid(t *testing.T) {
+ doc, err := unmarshalSchemaYAML([]byte(`
+spec_version: v1
+name: payments
+fields:
+ payments.min: { type: integer }
+ payments.max: { type: integer }
+validations:
+ - path: payments
+ rule: "self.payments.min < self.payments.max"
+ message: "min must be less than max"
+`))
+ require.NoError(t, err)
+ require.Len(t, doc.Validations, 1)
+ assert.Equal(t, "payments", doc.Validations[0].Path)
+ assert.Equal(t, "self.payments.min < self.payments.max", doc.Validations[0].Rule)
+ assert.Equal(t, "min must be less than max", doc.Validations[0].Message)
+}
+
+func TestUnmarshalSchemaYAML_Validations_RejectsEmptyRule(t *testing.T) {
+ _, err := unmarshalSchemaYAML([]byte(`
+spec_version: v1
+name: payments
+fields:
+ payments.x: { type: string }
+validations:
+ - rule: ""
+ message: "msg"
+`))
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "rule is required")
+}
+
+func TestUnmarshalSchemaYAML_Validations_RejectsBadSeverity(t *testing.T) {
+ _, err := unmarshalSchemaYAML([]byte(`
+spec_version: v1
+name: payments
+fields:
+ payments.x: { type: string }
+validations:
+ - rule: "self.payments.x != ''"
+ message: "msg"
+ severity: critical
+`))
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), `"critical"`)
+}
diff --git a/internal/schema/yaml.go b/internal/schema/yaml.go
index 51f7f378..80bab3fc 100644
--- a/internal/schema/yaml.go
+++ b/internal/schema/yaml.go
@@ -47,7 +47,22 @@ type SchemaYAML struct {
// rules. Keys and values must reference paths defined in Fields. Matches
// the JSON Schema 2020-12 keyword of the same name.
DependentRequired map[string][]string `yaml:"dependentRequired,omitempty"`
- Extensions map[string]any `yaml:",inline"`
+ // Validations declares cross-field rules expressed in CEL. Reserved in
+ // v0.1.0 — the parser persists rules and round-trips them, but the
+ // engine that compiles and evaluates them ships in Phase 2 (issue #76).
+ Validations []ValidationYAML `yaml:"validations,omitempty"`
+ Extensions map[string]any `yaml:",inline"`
+}
+
+// ValidationYAML mirrors a single ValidationRule entry on the wire.
+// Fields match the proto message but use YAML naming conventions.
+type ValidationYAML struct {
+ Path string `yaml:"path,omitempty"`
+ Rule string `yaml:"rule"`
+ Message string `yaml:"message"`
+ Severity string `yaml:"severity,omitempty"`
+ Reason string `yaml:"reason,omitempty"`
+ Extensions map[string]any `yaml:",inline"`
}
// SchemaInfoYAML contains optional schema-level metadata.
@@ -159,6 +174,9 @@ func validateSchemaYAML(doc *SchemaYAML) error {
if err := validateDependentRequiredYAML(doc); err != nil {
return err
}
+ if err := validateValidationsYAML(doc); err != nil {
+ return err
+ }
for path, f := range doc.Fields {
if !fieldPathPattern.MatchString(path) {
return fmt.Errorf("invalid field path %q: must match %s", path, fieldPathPattern)
@@ -218,6 +236,40 @@ func validateDependentRequiredYAML(doc *SchemaYAML) error {
return nil
}
+// validateValidationsYAML structurally lint-checks the `validations:` list
+// at schema-validate time. v0.1.0 reserves the key in the format spec but
+// does not yet ship the CEL engine — so this routine only enforces what
+// the wire shape requires:
+//
+// - rule and message are required, non-empty
+// - severity is empty or one of "error" | "warning"
+//
+// CEL compilation, field-reference resolution, and contradiction
+// detection are deferred to Phase 2 (see .agents/context/cel-validation.md).
+func validateValidationsYAML(doc *SchemaYAML) error {
+ if len(doc.Validations) == 0 {
+ return nil
+ }
+ for i, v := range doc.Validations {
+ if v.Rule == "" {
+ return fmt.Errorf("validations[%d]: rule is required", i)
+ }
+ if v.Message == "" {
+ return fmt.Errorf("validations[%d]: message is required", i)
+ }
+ switch v.Severity {
+ case "", "error", "warning":
+ // ok
+ default:
+ return fmt.Errorf("validations[%d]: severity %q must be \"error\" or \"warning\"", i, v.Severity)
+ }
+ if err := validateExtensions(fmt.Sprintf("validations[%d]", i), v.Extensions); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
// validateExtensions rejects any keys in the inline-extension map that do not
// match the x-* vendor-extension pattern. The path prefix is included in the
// error so users can locate the offending key in large documents.
@@ -296,6 +348,7 @@ func schemaToYAML(s *pb.Schema) *SchemaYAML {
Info: schemaInfoToYAML(s.Info),
Fields: make(map[string]SchemaFieldYAML, len(s.Fields)),
DependentRequired: protoDependentRequiredToYAML(s.DependentRequired),
+ Validations: protoValidationsToYAML(s.Validations),
}
for _, f := range s.Fields {
@@ -402,6 +455,46 @@ func protoConstraintsToYAML(c *pb.FieldConstraints) *ConstraintsYAML {
// --- YAML → Proto ---
+// yamlToProtoValidations converts the YAML validations list into proto
+// ValidationRule entries. Order is preserved — schema authors typically
+// expect rules to appear in the same order they declared them.
+func yamlToProtoValidations(in []ValidationYAML) []*pb.ValidationRule {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make([]*pb.ValidationRule, 0, len(in))
+ for _, v := range in {
+ out = append(out, &pb.ValidationRule{
+ Path: v.Path,
+ Rule: v.Rule,
+ Message: v.Message,
+ Severity: v.Severity,
+ Reason: v.Reason,
+ })
+ }
+ return out
+}
+
+// protoValidationsToYAML converts proto ValidationRule entries back to
+// YAML form for export. Returns nil for an empty input so the YAML key
+// is omitted.
+func protoValidationsToYAML(in []*pb.ValidationRule) []ValidationYAML {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make([]ValidationYAML, 0, len(in))
+ for _, v := range in {
+ out = append(out, ValidationYAML{
+ Path: v.Path,
+ Rule: v.Rule,
+ Message: v.Message,
+ Severity: v.Severity,
+ Reason: v.Reason,
+ })
+ }
+ return out
+}
+
// yamlToProtoDependentRequired converts the YAML map
// shape into the proto repeated-entry shape. Returns nil for an empty map so
// the wire format stays compact.
diff --git a/internal/storage/dbstore/models.gen.go b/internal/storage/dbstore/models.gen.go
index 8edba060..804ed261 100644
--- a/internal/storage/dbstore/models.gen.go
+++ b/internal/storage/dbstore/models.gen.go
@@ -128,6 +128,7 @@ type SchemaVersion struct {
Checksum string `json:"checksum"`
Published bool `json:"published"`
DependentRequired []byte `json:"dependent_required"`
+ Validations []byte `json:"validations"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
diff --git a/internal/storage/dbstore/schemas.sql.gen.go b/internal/storage/dbstore/schemas.sql.gen.go
index 45a61023..bc5ebfa0 100644
--- a/internal/storage/dbstore/schemas.sql.gen.go
+++ b/internal/storage/dbstore/schemas.sql.gen.go
@@ -113,9 +113,9 @@ func (q *Queries) CreateSchemaField(ctx context.Context, arg CreateSchemaFieldPa
}
const createSchemaVersion = `-- name: CreateSchemaVersion :one
-INSERT INTO schema_versions (schema_id, version, parent_version, description, checksum, dependent_required)
-VALUES ($1, $2, $3, $4, $5, $6)
-RETURNING id, schema_id, version, parent_version, description, checksum, published, dependent_required, created_at
+INSERT INTO schema_versions (schema_id, version, parent_version, description, checksum, dependent_required, validations)
+VALUES ($1, $2, $3, $4, $5, $6, $7)
+RETURNING id, schema_id, version, parent_version, description, checksum, published, dependent_required, validations, created_at
`
type CreateSchemaVersionParams struct {
@@ -125,6 +125,7 @@ type CreateSchemaVersionParams struct {
Description *string `json:"description"`
Checksum string `json:"checksum"`
DependentRequired []byte `json:"dependent_required"`
+ Validations []byte `json:"validations"`
}
func (q *Queries) CreateSchemaVersion(ctx context.Context, arg CreateSchemaVersionParams) (SchemaVersion, error) {
@@ -135,6 +136,7 @@ func (q *Queries) CreateSchemaVersion(ctx context.Context, arg CreateSchemaVersi
arg.Description,
arg.Checksum,
arg.DependentRequired,
+ arg.Validations,
)
var i SchemaVersion
err := row.Scan(
@@ -146,6 +148,7 @@ func (q *Queries) CreateSchemaVersion(ctx context.Context, arg CreateSchemaVersi
&i.Checksum,
&i.Published,
&i.DependentRequired,
+ &i.Validations,
&i.CreatedAt,
)
return i, err
@@ -176,7 +179,7 @@ func (q *Queries) DeleteSchemaField(ctx context.Context, arg DeleteSchemaFieldPa
}
const getLatestSchemaVersion = `-- name: GetLatestSchemaVersion :one
-SELECT id, schema_id, version, parent_version, description, checksum, published, dependent_required, created_at FROM schema_versions
+SELECT id, schema_id, version, parent_version, description, checksum, published, dependent_required, validations, created_at FROM schema_versions
WHERE schema_id = $1
ORDER BY version DESC
LIMIT 1
@@ -194,6 +197,7 @@ func (q *Queries) GetLatestSchemaVersion(ctx context.Context, schemaID pgtype.UU
&i.Checksum,
&i.Published,
&i.DependentRequired,
+ &i.Validations,
&i.CreatedAt,
)
return i, err
@@ -280,7 +284,7 @@ func (q *Queries) GetSchemaFields(ctx context.Context, schemaVersionID pgtype.UU
}
const getSchemaVersion = `-- name: GetSchemaVersion :one
-SELECT id, schema_id, version, parent_version, description, checksum, published, dependent_required, created_at FROM schema_versions
+SELECT id, schema_id, version, parent_version, description, checksum, published, dependent_required, validations, created_at FROM schema_versions
WHERE schema_id = $1 AND version = $2
`
@@ -301,6 +305,7 @@ func (q *Queries) GetSchemaVersion(ctx context.Context, arg GetSchemaVersionPara
&i.Checksum,
&i.Published,
&i.DependentRequired,
+ &i.Validations,
&i.CreatedAt,
)
return i, err
@@ -346,7 +351,7 @@ func (q *Queries) ListSchemas(ctx context.Context, arg ListSchemasParams) ([]Sch
const publishSchemaVersion = `-- name: PublishSchemaVersion :one
UPDATE schema_versions SET published = true
WHERE schema_id = $1 AND version = $2
-RETURNING id, schema_id, version, parent_version, description, checksum, published, dependent_required, created_at
+RETURNING id, schema_id, version, parent_version, description, checksum, published, dependent_required, validations, created_at
`
type PublishSchemaVersionParams struct {
@@ -366,6 +371,7 @@ func (q *Queries) PublishSchemaVersion(ctx context.Context, arg PublishSchemaVer
&i.Checksum,
&i.Published,
&i.DependentRequired,
+ &i.Validations,
&i.CreatedAt,
)
return i, err
diff --git a/internal/storage/domain/types.go b/internal/storage/domain/types.go
index fde835de..370c56a5 100644
--- a/internal/storage/domain/types.go
+++ b/internal/storage/domain/types.go
@@ -48,7 +48,12 @@ type SchemaVersion struct {
// required when A present" rules. Empty array when no rules exist.
// Wire shape: [{trigger_field, dependent_fields[]}].
DependentRequired []byte
- CreatedAt time.Time
+ // Validations holds the JSON-encoded list of CEL validation rules
+ // reserved in v0.1.0 of the schema spec. Empty array when no rules
+ // exist. Wire shape: [{path, rule, message, severity?, reason?}].
+ // Engine ships in Phase 2 (issue #76); v0.1.0 only round-trips.
+ Validations []byte
+ CreatedAt time.Time
}
// SchemaField represents a field definition within a schema version.
diff --git a/proto/centralconfig/v1/types.proto b/proto/centralconfig/v1/types.proto
index 15e484cc..1f212703 100644
--- a/proto/centralconfig/v1/types.proto
+++ b/proto/centralconfig/v1/types.proto
@@ -235,6 +235,13 @@ message Schema {
// field; trigger may not appear in its own dependents). Enforced at every
// config write against the post-merge snapshot.
repeated DependentRequiredEntry dependent_required = 12;
+
+ // Cross-field rule expressions reserved for future Common Expression
+ // Language (CEL) evaluation. Stored on the schema and round-tripped
+ // through ImportSchema/GetSchema; the runtime engine ships separately
+ // (see issue #76). Reserving the key in v0.1.0 of the schema spec
+ // avoids a breaking meta-schema change later.
+ repeated ValidationRule validations = 13;
}
// DependentRequiredEntry encodes one cross-field requirement: when the
@@ -251,6 +258,40 @@ message DependentRequiredEntry {
repeated string dependent_fields = 2;
}
+// ValidationRule encodes one cross-field rule expressed in Common
+// Expression Language (CEL). Reserved in v0.1.0 of the schema spec — the
+// parser accepts and persists rules, but the engine that compiles and
+// evaluates them ships separately (see issue #76 / .agents/context/cel-validation.md).
+//
+// Rules are scoped to a path prefix: an empty path means a schema-wide
+// rule; a non-empty path anchors the rule to a group for documentation
+// and UI grouping (the binding namespace itself always exposes every
+// field via `self`, regardless of path).
+message ValidationRule {
+ // Optional path prefix scoping the rule to a group of fields. Empty
+ // string means the rule applies at schema scope.
+ string path = 1;
+
+ // The CEL expression source. Lint at ImportSchema in v0.1.0 only checks
+ // that the string is non-empty; CEL compilation happens in Phase 2 once
+ // the engine ships.
+ string rule = 2;
+
+ // Human-readable failure message shown to clients when the rule
+ // rejects a write. Required.
+ string message = 3;
+
+ // Optional severity hint. Reserved values: "error" (default — write
+ // rejected) and "warning" (write accepted, surfaced for UI). v0.1.0
+ // only validates the value is empty or one of the reserved set; the
+ // warning path is not yet enforced.
+ string severity = 4;
+
+ // Optional machine-readable failure code for SDK consumers that want
+ // to branch on rule outcome without parsing the message text.
+ string reason = 5;
+}
+
// Tenant represents an organization or entity that has its own configuration
// based on an assigned schema version.
message Tenant {