Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 191 additions & 71 deletions api/centralconfig/v1/types.pb.go

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions cmd/server/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions db/migrations/001_initial_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Expand Down
4 changes: 2 additions & 2 deletions db/queries/schemas.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions docs/api/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- [Tenant](#centralconfig-v1-Tenant)
- [TypedValue](#centralconfig-v1-TypedValue)
- [UsageStats](#centralconfig-v1-UsageStats)
- [ValidationRule](#centralconfig-v1-ValidationRule)

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

Expand Down Expand Up @@ -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. |



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




<a name="centralconfig-v1-ValidationRule"></a>

### 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: &#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. |
| reason | [string](#string) | | Optional machine-readable failure code for SDK consumers that want to branch on rule outcome without parsing the message text. |








Expand Down
34 changes: 34 additions & 0 deletions docs/api/openapi.swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions internal/schema/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
15 changes: 11 additions & 4 deletions internal/schema/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Expand All @@ -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),
Expand All @@ -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)
Expand All @@ -690,14 +696,15 @@ 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,
ParentVersion: &latestVersion.Version,
Description: ptrString(doc.VersionDescription),
Checksum: checksum,
DependentRequired: depReqJSON,
Validations: validationsJSON,
})
if err != nil {
s.logger.ErrorContext(ctx, "import: create new version", "error", err)
Expand Down
3 changes: 3 additions & 0 deletions internal/schema/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions internal/schema/store_memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions internal/schema/store_pg.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,18 @@ 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,
ParentVersion: arg.ParentVersion,
Description: arg.Description,
Checksum: arg.Checksum,
DependentRequired: depReq,
Validations: validations,
})
if err != nil {
return domain.SchemaVersion{}, err
Expand Down Expand Up @@ -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),
}
}
Expand Down
65 changes: 65 additions & 0 deletions internal/schema/validations.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading