Skip to content

Commit 940ffdc

Browse files
authored
feat(schema): reserve validations key in schema spec v0.1.0
Reserves the top-level `validations:` key in schema spec v0.1.0 — proto, DB, YAML parser, lint, storage round-trip — without shipping the CEL engine. The engine and runtime evaluation land in Phase 2 (#76). Wire format mirrors Kubernetes CRD x-kubernetes-validations and buf/protovalidate per the cel-validation design brief: each rule carries a path prefix, the CEL expression source, a human-readable failure message, an optional severity hint (error / warning), and an optional machine-readable reason code. v0.1.0 lint at ImportSchema is structural only — rule and message required, severity optional and constrained to error / warning, x-* extension keys accepted. CEL compilation, field-reference resolution, and contradiction detection all defer to Phase 2 once the engine ships. Why now: spec v0.1.0 freezes the meta-schema. Reserving the key now costs ~50 lines of plumbing; adding it later is a breaking spec bump that downstream tooling has to handle. Schema authors who want cross-field rules today can write CEL expressions; ImportSchema accepts and persists them, and they become no-ops at write time until Phase 2. Closes #192. Final Phase-1 subtask of the cross-field validation umbrella (#76); pairs with #193 (dependentRequired) and #194 (prefix-overlap lint), both already in main.
1 parent a8c766a commit 940ffdc

18 files changed

Lines changed: 771 additions & 85 deletions

File tree

api/centralconfig/v1/types.pb.go

Lines changed: 191 additions & 71 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/server/openapi.json

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

db/migrations/001_initial_schema.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ CREATE TABLE schema_versions (
3434
-- JSON array of {trigger_field, dependent_fields} entries encoding the
3535
-- schema's dependentRequired rules. Empty array when no rules exist.
3636
dependent_required JSONB NOT NULL DEFAULT '[]',
37+
-- JSON array of {path, rule, message, severity?, reason?} entries
38+
-- encoding the schema's CEL validation rules. Reserved in v0.1.0 —
39+
-- parser persists; runtime engine ships in Phase 2 (see issue #76).
40+
validations JSONB NOT NULL DEFAULT '[]',
3741
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
3842
UNIQUE(schema_id, version)
3943
);

db/queries/schemas.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ LIMIT $1 OFFSET $2;
1818
DELETE FROM schemas WHERE id = $1;
1919

2020
-- name: CreateSchemaVersion :one
21-
INSERT INTO schema_versions (schema_id, version, parent_version, description, checksum, dependent_required)
22-
VALUES ($1, $2, $3, $4, $5, $6)
21+
INSERT INTO schema_versions (schema_id, version, parent_version, description, checksum, dependent_required, validations)
22+
VALUES ($1, $2, $3, $4, $5, $6, $7)
2323
RETURNING *;
2424

2525
-- name: GetSchemaVersion :one

docs/api/api-reference.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
- [Tenant](#centralconfig-v1-Tenant)
2424
- [TypedValue](#centralconfig-v1-TypedValue)
2525
- [UsageStats](#centralconfig-v1-UsageStats)
26+
- [ValidationRule](#centralconfig-v1-ValidationRule)
2627

2728
- [FieldType](#centralconfig-v1-FieldType)
2829

@@ -344,6 +345,7 @@ Each schema is versioned — updates create new immutable versions.
344345
| created_at | [google.protobuf.Timestamp](#google-protobuf-Timestamp) | | When this version was created. |
345346
| info | [SchemaInfo](#centralconfig-v1-SchemaInfo) | | Optional schema metadata: ownership, contact, labels. |
346347
| 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. |
348+
| 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. |
347349

348350

349351

@@ -515,6 +517,33 @@ UsageStats represents aggregated read usage statistics for a config field.
515517

516518

517519

520+
521+
<a name="centralconfig-v1-ValidationRule"></a>
522+
523+
### ValidationRule
524+
ValidationRule encodes one cross-field rule expressed in Common
525+
Expression Language (CEL). Reserved in v0.1.0 of the schema spec — the
526+
parser accepts and persists rules, but the engine that compiles and
527+
evaluates them ships separately (see issue #76 / .agents/context/cel-validation.md).
528+
529+
Rules are scoped to a path prefix: an empty path means a schema-wide
530+
rule; a non-empty path anchors the rule to a group for documentation
531+
and UI grouping (the binding namespace itself always exposes every
532+
field via `self`, regardless of path).
533+
534+
535+
| Field | Type | Label | Description |
536+
| ----- | ---- | ----- | ----------- |
537+
| path | [string](#string) | | Optional path prefix scoping the rule to a group of fields. Empty string means the rule applies at schema scope. |
538+
| 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. |
539+
| message | [string](#string) | | Human-readable failure message shown to clients when the rule rejects a write. Required. |
540+
| severity | [string](#string) | | Optional severity hint. Reserved values: &#34;error&#34; (default — write rejected) and &#34;warning&#34; (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. |
541+
| reason | [string](#string) | | Optional machine-readable failure code for SDK consumers that want to branch on rule outcome without parsing the message text. |
542+
543+
544+
545+
546+
518547

519548

520549

docs/api/openapi.swagger.json

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/schema/convert.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ func schemaToProto(s domain.Schema, v domain.SchemaVersion, fields []domain.Sche
4141
if entries := UnmarshalDependentRequired(v.DependentRequired); len(entries) > 0 {
4242
result.DependentRequired = entries
4343
}
44+
if rules := UnmarshalValidations(v.Validations); len(rules) > 0 {
45+
result.Validations = rules
46+
}
4447
return result
4548
}
4649

internal/schema/service.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,11 @@ func (s *Service) ImportSchema(ctx context.Context, req *pb.ImportSchemaRequest)
616616
if err != nil {
617617
return nil, status.Errorf(codes.Internal, "failed to encode dependentRequired: %v", err)
618618
}
619+
validations := yamlToProtoValidations(doc.Validations)
620+
validationsJSON, err := MarshalValidations(validations)
621+
if err != nil {
622+
return nil, status.Errorf(codes.Internal, "failed to encode validations: %v", err)
623+
}
619624
checksum := computeChecksum(fields)
620625

621626
// Check if schema already exists by name.
@@ -626,7 +631,7 @@ func (s *Service) ImportSchema(ctx context.Context, req *pb.ImportSchemaRequest)
626631

627632
if errors.Is(err, domain.ErrNotFound) {
628633
// New schema — create with v1.
629-
resp, err := s.importCreateNew(ctx, doc, fields, checksum, depReqJSON)
634+
resp, err := s.importCreateNew(ctx, doc, fields, checksum, depReqJSON, validationsJSON)
630635
if err != nil || !req.AutoPublish {
631636
return resp, err
632637
}
@@ -651,14 +656,14 @@ func (s *Service) ImportSchema(ctx context.Context, req *pb.ImportSchemaRequest)
651656
}
652657

653658
// Create new version.
654-
resp, err := s.importNewVersion(ctx, existing, latestVersion, doc, fields, checksum, depReqJSON)
659+
resp, err := s.importNewVersion(ctx, existing, latestVersion, doc, fields, checksum, depReqJSON, validationsJSON)
655660
if err != nil || !req.AutoPublish {
656661
return resp, err
657662
}
658663
return s.autoPublish(ctx, resp)
659664
}
660665

661-
func (s *Service) importCreateNew(ctx context.Context, doc *SchemaYAML, fields []*pb.SchemaField, checksum string, depReqJSON []byte) (*pb.ImportSchemaResponse, error) {
666+
func (s *Service) importCreateNew(ctx context.Context, doc *SchemaYAML, fields []*pb.SchemaField, checksum string, depReqJSON, validationsJSON []byte) (*pb.ImportSchemaResponse, error) {
662667
schema, err := s.store.CreateSchema(ctx, CreateSchemaParams{
663668
Name: doc.Name,
664669
Description: ptrString(doc.Description),
@@ -674,6 +679,7 @@ func (s *Service) importCreateNew(ctx context.Context, doc *SchemaYAML, fields [
674679
Description: ptrString(doc.VersionDescription),
675680
Checksum: checksum,
676681
DependentRequired: depReqJSON,
682+
Validations: validationsJSON,
677683
})
678684
if err != nil {
679685
s.logger.ErrorContext(ctx, "import: create version", "error", err)
@@ -690,14 +696,15 @@ func (s *Service) importCreateNew(ctx context.Context, doc *SchemaYAML, fields [
690696
}, nil
691697
}
692698

693-
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) {
699+
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) {
694700
newVersion, err := s.store.CreateSchemaVersion(ctx, CreateSchemaVersionParams{
695701
SchemaID: schema.ID,
696702
Version: latestVersion.Version + 1,
697703
ParentVersion: &latestVersion.Version,
698704
Description: ptrString(doc.VersionDescription),
699705
Checksum: checksum,
700706
DependentRequired: depReqJSON,
707+
Validations: validationsJSON,
701708
})
702709
if err != nil {
703710
s.logger.ErrorContext(ctx, "import: create new version", "error", err)

internal/schema/store.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ type CreateSchemaVersionParams struct {
2323
// Pass an empty []byte (or nil) when no rules exist; the store will
2424
// persist `[]` so reads always return well-formed JSON.
2525
DependentRequired []byte
26+
// Validations is the JSON-encoded list of CEL validation rules
27+
// reserved in v0.1.0. Same nil-safe semantics as DependentRequired.
28+
Validations []byte
2629
}
2730

2831
// GetSchemaVersionParams identifies a specific schema version.

internal/schema/store_memory.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ func (m *MemoryStore) CreateSchemaVersion(_ context.Context, arg CreateSchemaVer
145145
if len(depReq) == 0 {
146146
depReq = []byte("[]")
147147
}
148+
validations := arg.Validations
149+
if len(validations) == 0 {
150+
validations = []byte("[]")
151+
}
148152
sv := domain.SchemaVersion{
149153
ID: m.nextID(),
150154
SchemaID: arg.SchemaID,
@@ -154,6 +158,7 @@ func (m *MemoryStore) CreateSchemaVersion(_ context.Context, arg CreateSchemaVer
154158
Checksum: arg.Checksum,
155159
Published: false,
156160
DependentRequired: depReq,
161+
Validations: validations,
157162
CreatedAt: time.Now(),
158163
}
159164
m.schemaVersions[sv.ID] = sv

0 commit comments

Comments
 (0)