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
2 changes: 1 addition & 1 deletion .agents/context/schema-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ Meta-schema encodes this via `allOf` with 4 `if/then` branches keyed on `type`.

### Cross-cutting

- #129 — Pluggable schema parser: extract v1 parser behind an interface so future spec versions register as a single package + init(). Unblocks v2 without branches across the codebase.
- #129 — Pluggable schema parser. `Parser` interface + package-level registry in `internal/schema/parser.go` (and symmetric in `internal/config/parser.go`). Each spec version is a single sibling file (`parser_v1.go`, future `parser_v2.go`) that declares a parser struct and registers it via `init()`. The service layer dispatches through `schema.Dispatch(yaml)` on import and `schema.MarshalSchemaAt(s, version)` on export; layer-2 semantic checks run on the proto value after dispatch so they're shared across versions. Adding v2 means landing one new file; nothing else in the codebase changes.
- #76 (Phase 1) — Reserve `validations:` and `dependentRequired:` keys in meta-schema + parser. No engine; rules round-trip through ImportSchema/GetSchema unevaluated. See [cel-validation.md](cel-validation.md). Must land in v0.1.0 to lock the schema shape.

## Open questions
Expand Down
21 changes: 17 additions & 4 deletions api/centralconfig/v1/config_service.pb.go

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

21 changes: 17 additions & 4 deletions api/centralconfig/v1/schema_service.pb.go

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

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

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

6 changes: 3 additions & 3 deletions coverage-thresholds.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"cmd/decree": 86.6,
"internal": 80.0,
"sdk/adminclient": 97.2,
"internal": 81.9,
"sdk/adminclient": 97.6,
"sdk/configclient": 87.1,
"sdk/configwatcher": 90.9,
"sdk/tools": 95.8
"sdk/tools": 96.1
}
2 changes: 2 additions & 0 deletions docs/api/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,7 @@ Usage statistics are tracked asynchronously via batched read counters.
| ----- | ---- | ----- | ----------- |
| tenant_id | [string](#string) | | Tenant ID (UUID). |
| version | [int32](#int32) | optional | Config version to export. If omitted, exports the latest version. |
| spec_version | [string](#string) | optional | Config-format spec version to emit (e.g. "v1"). When omitted, defaults to the highest version the server supports. The server returns InvalidArgument if the requested version is not registered. |



Expand Down Expand Up @@ -1307,6 +1308,7 @@ bypasses the cache and reads directly from the database.
| ----- | ---- | ----- | ----------- |
| id | [string](#string) | | Schema ID (UUID). |
| version | [int32](#int32) | optional | Schema version to export. If omitted, exports the latest version. |
| spec_version | [string](#string) | optional | Schema-format spec version to emit (e.g. "v1"). When omitted, defaults to the highest version the server supports. The server returns InvalidArgument if the requested version is not registered. |



Expand Down
14 changes: 14 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.

8 changes: 8 additions & 0 deletions docs/concepts/meta-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ https://schemas.opendecree.io/schema/v{MAJOR}.{MINOR}.{PATCH}/decree-{schema|con

**Post-1.0.0 (future):** switch to major-only paths (`/v1/`) with permanent redirects from historical full-SemVer URLs. Matches the OpenAPI 3.x dated-URL practice.

## Multi-version evolution

The server supports multiple schema-format versions concurrently — schemas imported under `v1` continue to work after `v2` is registered, and the export path can emit either. Internally, each spec version is a small parser plug-in that registers itself in a package-level registry; adding `v2` means landing a single new file (e.g. `parser_v2.go`) that declares the parser and calls `init() { Register(&v2Parser{}) }`. The service layer dispatches through the registry on both `ImportSchema` and `ExportSchema` paths.

`ExportSchema` (and `ExportConfig`) accept an optional `spec_version` request field — when omitted, the server emits the highest version it supports. Unknown values produce `InvalidArgument` with the supported version list in the error message.

This pattern keeps the v1 code unchanged when v2 lands and gives the server a single registry of versions to advertise to clients.

## Stability policy

The meta-schema follows SemVer. Versions are bumped in a separate cadence from the decree server release; the meta-schema version is always present in the URL.
Expand Down
140 changes: 140 additions & 0 deletions internal/config/parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package config

import (
"fmt"
"sort"
"sync"

"gopkg.in/yaml.v3"

"github.com/opendecree/decree/internal/storage/domain"
)

// Parser converts a single spec_version of decree.config.yaml to and from
// the internal interchange shape. New versions of the config format
// register an implementation of this interface via init() in their own
// file under internal/config/ — see parser_v1.go for the v1
// implementation. The service layer dispatches through DispatchImport
// on import and MarshalConfigAt on export, so adding v2 support means
// landing a single new parser_v2.go file with init() and nothing else.
//
// Parser.Parse takes the schema's field types as input so it can coerce
// YAML-native primitives (numbers, bools, durations) to the canonical
// string representation the storage layer expects. Layer-2 semantic
// checks (field locks, per-field validation, dependentRequired) run at
// the service layer on the parser's output — not inside Parse — so
// different versions can share the same enforcement.
type Parser interface {
// SpecVersion is the literal `spec_version:` value this parser handles
// (e.g. "v1"). Used as the registry key.
SpecVersion() string

// Parse converts YAML bytes to a parsed import. fieldTypes maps each
// field path to its declared schema type so the parser can coerce
// typed YAML primitives to the canonical string representation.
// Returns an error on malformed YAML or type-mismatch between a YAML
// value and its declared field type.
Parse(data []byte, fieldTypes map[string]domain.FieldType) (ParsedImport, error)

// Marshal converts a list of stored config rows back to YAML bytes for
// export. fieldTypes is needed so the parser can render strings as
// their typed YAML form.
Marshal(version int32, description string, rows []configRow, fieldTypes map[string]domain.FieldType) ([]byte, error)
}

// ParsedImport carries everything the service layer needs from a parsed
// import: the values to write, plus the document's top-level description
// (used as the audit description if the request did not supply one).
// Future fields — e.g. an explicit `version` declared by the YAML — slot
// in here without churning the Parser interface signature.
type ParsedImport struct {
Description string
Values []configValueImport
}

var (
parsersMu sync.RWMutex
parsers = map[string]Parser{}
)

// Register adds a parser to the registry. Called from each parser's init()
// function. Panics on duplicate registration — that is a programming error
// at compile time, not a runtime input.
func Register(p Parser) {
parsersMu.Lock()
defer parsersMu.Unlock()
v := p.SpecVersion()
if _, exists := parsers[v]; exists {
panic(fmt.Sprintf("config: duplicate parser registered for spec_version %q", v))
}
parsers[v] = p
}

// SupportedVersions returns the registered spec_version values in sorted
// order. Used in error messages so users see which versions the server
// accepts.
func SupportedVersions() []string {
parsersMu.RLock()
defer parsersMu.RUnlock()
out := make([]string, 0, len(parsers))
for v := range parsers {
out = append(out, v)
}
sort.Strings(out)
return out
}

// LatestVersion returns the highest registered spec_version (lexicographic
// order — sufficient while versions follow a "v1", "v2", … pattern). Used
// as the default when ExportConfig is called without an explicit version.
func LatestVersion() string {
versions := SupportedVersions()
if len(versions) == 0 {
return ""
}
return versions[len(versions)-1]
}

// peekHeader is a minimal struct used to extract spec_version from a YAML
// document before handing the bytes to the version-specific parser.
type peekHeader struct {
SpecVersion string `yaml:"spec_version"`
}

// DispatchImport routes an incoming config YAML document to the parser
// registered for its spec_version. Returns an error naming the supported
// versions if spec_version is missing or unrecognized; otherwise delegates
// to the matching parser.
func DispatchImport(data []byte, fieldTypes map[string]domain.FieldType) (ParsedImport, error) {
var hdr peekHeader
if err := yaml.Unmarshal(data, &hdr); err != nil {
return ParsedImport{}, fmt.Errorf("invalid YAML: %w", err)
}
if hdr.SpecVersion == "" {
return ParsedImport{}, fmt.Errorf("spec_version is required (supported: %v)", SupportedVersions())
}
parsersMu.RLock()
p, ok := parsers[hdr.SpecVersion]
parsersMu.RUnlock()
if !ok {
return ParsedImport{}, fmt.Errorf("unsupported spec_version %q (supported: %v)", hdr.SpecVersion, SupportedVersions())
}
return p.Parse(data, fieldTypes)
}

// MarshalConfigAt selects the parser for the requested spec_version and
// emits the config in that version's wire format. version may be empty —
// in which case LatestVersion is used. Returns an error if no parser is
// registered for the requested version.
func MarshalConfigAt(version int32, description string, rows []configRow, fieldTypes map[string]domain.FieldType, specVersion string) ([]byte, error) {
if specVersion == "" {
specVersion = LatestVersion()
}
parsersMu.RLock()
p, ok := parsers[specVersion]
parsersMu.RUnlock()
if !ok {
return nil, fmt.Errorf("unsupported spec_version %q (supported: %v)", specVersion, SupportedVersions())
}
return p.Marshal(version, description, rows, fieldTypes)
}
Loading
Loading