Support integer schema versions through typed decoding
This commit is contained in:
56
decode.go
56
decode.go
@@ -34,7 +34,15 @@ type decodedConfiguration struct {
|
||||
final *document.Node
|
||||
}
|
||||
|
||||
type decodeContext struct {
|
||||
versionPath *Path
|
||||
}
|
||||
|
||||
func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input *document.Node) (decodedConfiguration, []decodeIssue) {
|
||||
return decodeConfigurationWithContext(schema, defaults, input, decodeContext{})
|
||||
}
|
||||
|
||||
func decodeConfigurationWithContext(schema *schemaDescriptor, defaults reflect.Value, input *document.Node, context decodeContext) (decodedConfiguration, []decodeIssue) {
|
||||
if schema == nil || schema.root == nil || !defaults.IsValid() || defaults.Type() != schema.root.typeOf {
|
||||
return decodedConfiguration{}, []decodeIssue{{code: CodeSchemaUnsupportedType, message: "configuration defaults do not match the schema"}}
|
||||
}
|
||||
@@ -50,7 +58,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
|
||||
presence := make(map[string]inputPresence)
|
||||
if input != nil {
|
||||
collectPresence(input, Path{}, presence)
|
||||
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema)
|
||||
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema, context)
|
||||
if len(issues) != 0 {
|
||||
return decodedConfiguration{}, issues
|
||||
}
|
||||
@@ -71,7 +79,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
||||
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind == document.Null {
|
||||
switch descriptor.kind {
|
||||
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
|
||||
@@ -89,7 +97,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
||||
} else {
|
||||
elementBase = base.Elem()
|
||||
}
|
||||
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema)
|
||||
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context)
|
||||
if len(issues) != 0 {
|
||||
return reflect.Value{}, issues
|
||||
}
|
||||
@@ -100,17 +108,17 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
||||
|
||||
switch descriptor.kind {
|
||||
case descriptorStruct:
|
||||
return decodeStruct(node, base, descriptor, path, schema)
|
||||
return decodeStruct(node, base, descriptor, path, schema, context)
|
||||
case descriptorMap:
|
||||
return decodeMap(node, base, descriptor, path, schema)
|
||||
return decodeMap(node, base, descriptor, path, schema, context)
|
||||
case descriptorSlice:
|
||||
return decodeSlice(node, descriptor, path, schema)
|
||||
return decodeSlice(node, descriptor, path, schema, context)
|
||||
case descriptorArray:
|
||||
return decodeArray(node, descriptor, path, schema)
|
||||
return decodeArray(node, descriptor, path, schema, context)
|
||||
case descriptorDynamic:
|
||||
return decodeDynamic(node, base, path, schema)
|
||||
return decodeDynamic(node, base, path, schema, context)
|
||||
case descriptorString:
|
||||
if node.Kind != document.String {
|
||||
if node.Kind != document.String && !(pathMatches(context.versionPath, path) && isIntegerVersion(node)) {
|
||||
return typeMismatch(path, node, "string")
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
@@ -187,7 +195,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
||||
}
|
||||
}
|
||||
|
||||
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
||||
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind != document.Mapping {
|
||||
return typeMismatch(path, node, "mapping")
|
||||
}
|
||||
@@ -203,7 +211,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
||||
issues = append(issues, decodeIssue{code: CodeSchemaUnsupportedType, path: childPath, message: "configuration field could not be addressed", location: entry.ValueLocation})
|
||||
continue
|
||||
}
|
||||
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema)
|
||||
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema, context)
|
||||
if len(fieldIssues) != 0 {
|
||||
issues = append(issues, fieldIssues...)
|
||||
continue
|
||||
@@ -212,7 +220,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
||||
continue
|
||||
}
|
||||
if descriptor.inlineMap != nil {
|
||||
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema); issue != nil {
|
||||
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema, context); issue != nil {
|
||||
issues = append(issues, issue...)
|
||||
}
|
||||
continue
|
||||
@@ -222,7 +230,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
||||
return result, issues
|
||||
}
|
||||
|
||||
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor) []decodeIssue {
|
||||
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor, context decodeContext) []decodeIssue {
|
||||
mapValue, ok := fieldForDecode(target, field.index, true)
|
||||
if !ok || mapValue.Kind() != reflect.Map {
|
||||
return []decodeIssue{{code: CodeSchemaInline, path: path, message: "inline map could not be addressed", location: entry.KeyLocation}}
|
||||
@@ -236,14 +244,14 @@ func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry doc
|
||||
if !base.IsValid() {
|
||||
base = reflect.Zero(mapValue.Type().Elem())
|
||||
}
|
||||
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema)
|
||||
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema, context)
|
||||
if len(issues) == 0 {
|
||||
mapValue.SetMapIndex(key, decoded)
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
||||
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind != document.Mapping {
|
||||
return typeMismatch(path, node, "mapping")
|
||||
}
|
||||
@@ -259,7 +267,7 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
|
||||
if !entryBase.IsValid() {
|
||||
entryBase = reflect.Zero(descriptor.typeOf.Elem())
|
||||
}
|
||||
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema)
|
||||
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema, context)
|
||||
if len(entryIssues) != 0 {
|
||||
issues = append(issues, entryIssues...)
|
||||
continue
|
||||
@@ -269,14 +277,14 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
|
||||
return result, issues
|
||||
}
|
||||
|
||||
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
||||
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind != document.Sequence {
|
||||
return typeMismatch(path, node, "sequence")
|
||||
}
|
||||
result := reflect.MakeSlice(descriptor.typeOf, len(node.Sequence), len(node.Sequence))
|
||||
var issues []decodeIssue
|
||||
for i, child := range node.Sequence {
|
||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
|
||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
|
||||
if len(childIssues) != 0 {
|
||||
issues = append(issues, childIssues...)
|
||||
continue
|
||||
@@ -286,7 +294,7 @@ func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
||||
return result, issues
|
||||
}
|
||||
|
||||
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
||||
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind != document.Sequence {
|
||||
return typeMismatch(path, node, "sequence")
|
||||
}
|
||||
@@ -296,7 +304,7 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
var issues []decodeIssue
|
||||
for i, child := range node.Sequence {
|
||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
|
||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
|
||||
if len(childIssues) != 0 {
|
||||
issues = append(issues, childIssues...)
|
||||
continue
|
||||
@@ -306,13 +314,13 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
||||
return result, issues
|
||||
}
|
||||
|
||||
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
||||
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind == document.Mapping && base.IsValid() && !base.IsNil() {
|
||||
concrete := base.Elem()
|
||||
operation := valueOperation{schema: schema}
|
||||
descriptor, issue := operation.dynamicDescriptor(concrete.Type(), path)
|
||||
if issue == nil && acceptsMapping(descriptor) {
|
||||
decoded, issues := decodeValue(node, concrete, descriptor, path, schema)
|
||||
decoded, issues := decodeValue(node, concrete, descriptor, path, schema, context)
|
||||
if len(issues) != 0 {
|
||||
return reflect.Value{}, issues
|
||||
}
|
||||
@@ -347,7 +355,7 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
||||
sequence := make([]any, len(node.Sequence))
|
||||
var issues []decodeIssue
|
||||
for i, child := range node.Sequence {
|
||||
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema)
|
||||
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema, context)
|
||||
if len(childIssues) != 0 {
|
||||
issues = append(issues, childIssues...)
|
||||
continue
|
||||
@@ -362,7 +370,7 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
||||
mapping := make(map[string]any, len(node.Mapping))
|
||||
var issues []decodeIssue
|
||||
for _, entry := range node.Mapping {
|
||||
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema)
|
||||
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema, context)
|
||||
if len(childIssues) != 0 {
|
||||
issues = append(issues, childIssues...)
|
||||
continue
|
||||
|
||||
@@ -74,9 +74,12 @@ frozen redacted projection.
|
||||
### Application schema versions
|
||||
|
||||
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
|
||||
decoding, the checker receives a `Version` preserving absent, explicit null, or
|
||||
string input. Any other input kind is a `version.type` error and the checker does not
|
||||
run. ConfigKit does not interpret the text or impose a universal version path.
|
||||
decoding, the checker receives a `Version` preserving absent, explicit null, string,
|
||||
or strict decimal-integer input. Strings and integers supply their exact spelling in
|
||||
`Version.Text`; ConfigKit does not impose a machine-integer range. Fractional or
|
||||
exponent numbers, booleans, mappings, and sequences produce `version.type`, and the
|
||||
checker does not run. ConfigKit does not interpret the text or impose a universal
|
||||
version path.
|
||||
|
||||
The checker reports safe, value-free errors and warnings through `Validation.Error`
|
||||
and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or
|
||||
@@ -668,7 +671,11 @@ keys but preserve the strict schema of their values; inline maps accept unmatche
|
||||
keys according to their element descriptor. Multiple independent field failures are
|
||||
collected without publishing the partially decoded object.
|
||||
|
||||
Booleans and strings are not coerced. Integer destinations require integer syntax
|
||||
Booleans and strings are not generally coerced. At exactly a configured
|
||||
`VersionRule.Path`, strict decimal-integer input may decode into a plain `string`, a
|
||||
named string without a text codec, or pointer wrappers around those types. The
|
||||
exception is load-local and does not apply to unrelated fields or loads without a
|
||||
version rule. Integer destinations require integer syntax
|
||||
and a value in the destination type's range. Float destinations accept finite JSON
|
||||
numbers representable by their destination precision. Dynamic numbers become
|
||||
`int64` for integer syntax and `float64` for fractional or exponent syntax, with
|
||||
|
||||
@@ -12,7 +12,7 @@ four sanitized shapes using only ConfigKit and the Go standard library:
|
||||
| Shape | Demonstrated fit | Application code deliberately retained |
|
||||
| --- | --- | --- |
|
||||
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
|
||||
| Notarius-like | Canonical object bindings, a string schema version, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
||||
| Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
||||
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
|
||||
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
||||
|
||||
@@ -61,8 +61,9 @@ The examples preserve domain meaning while adopting one configuration convention
|
||||
domain rule. Loaded and constructed values use the same validator.
|
||||
- Canonical module and object bindings replace string shorthand. Unknown fields and
|
||||
incompatible option shapes fail at their semantic paths.
|
||||
- Schema versions are strings. Consumers with integer versions migrate their files
|
||||
instead of enabling another scalar convention.
|
||||
- Schema versions remain string-backed in application types. A configured
|
||||
`VersionRule` accepts string or strict decimal-integer YAML at its exact path and
|
||||
supplies the spelling to both the checker and a plain string destination.
|
||||
- Integers inside dynamic options are portable `int64` values. Runtime `int`
|
||||
conversion is explicit and range checked.
|
||||
- Strict YAML intentionally rejects duplicate keys, aliases, merge keys, unsupported
|
||||
|
||||
@@ -42,6 +42,10 @@ git show cb5b435:docs/roadmap/remediation.md
|
||||
Release-candidate, publication, and verification work follows the operational
|
||||
[release procedure](release.md).
|
||||
|
||||
Planned feature work: [string and integer schema versions](roadmap/integer-schema-versions.md)
|
||||
and its [sequential implementation plan](roadmap/implementation.md).
|
||||
These describe future behavior, not the current configuration contract.
|
||||
|
||||
## Baseline validation
|
||||
|
||||
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
|
||||
|
||||
152
docs/roadmap/implementation.md
Normal file
152
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Integer schema versions: implementation plan
|
||||
|
||||
Status: in progress. The [feature roadmap](integer-schema-versions.md) owns the
|
||||
intended behavior and architecture; this document owns execution and progress.
|
||||
|
||||
## Execution rules
|
||||
|
||||
Execute stages sequentially. Each stage is a bounded assignment for one
|
||||
gpt-5.6-terra coding agent: one coherent outcome, targeted files, focused tests,
|
||||
and a reviewable handoff. Do not delegate or parallelize dependent stages by
|
||||
default. No stage authorizes changing downstream repositories or publishing a tag.
|
||||
|
||||
Before each stage, read [development](../development.md), the relevant task-specific
|
||||
policies, the roadmap, and the preceding handoff. Inspect the worktree and preserve
|
||||
unrelated work. Prefer the repository's code graph for code discovery, and verify
|
||||
current symbols rather than assuming the file inventory below is frozen.
|
||||
|
||||
Keep every stage green: add behavioral coverage alongside its implementation, run
|
||||
the stated focused commands and `./scripts/check.sh`, and update implemented
|
||||
contracts in the same change. Do not relax tests unrelated to this feature.
|
||||
Record files changed, commands/results, remaining risks, and the next stage here.
|
||||
Commit a coherent stage when committing is authorized; otherwise hand off its diff.
|
||||
|
||||
## Stage 1 — Deliver the checker-to-decoder vertical slice
|
||||
|
||||
Status: complete (2026-09-12). Depends on: none.
|
||||
|
||||
Scope: `validation.go`, the private decoding plumbing in `decode.go` and
|
||||
`load_result.go`, necessary private call sites, and focused version/decode tests.
|
||||
|
||||
- Add the shared private integer-text recognition and extend `checkVersion`
|
||||
according to the roadmap. Preserve diagnostics, callback order, and public types.
|
||||
- Thread per-load version-path context to recursive decoding; enable integer to
|
||||
plain-string decoding only at that path. Do not mutate source/history trees or
|
||||
type-wide descriptors. Keep no-rule decoder callers strict.
|
||||
- Add public-load coverage for integer and string `4` into `string` and `*string`,
|
||||
identical checker text, absent/null states, and unsupported version kinds.
|
||||
- Keep the suite green by updating the existing integer-version rejection test in
|
||||
`load_test.go`. In `examples/consumers/consumers_test.go`, remove integer input
|
||||
from the structural-rejection expectation or convert it to the appropriate
|
||||
success/domain-policy assertion; do not merely delete migration coverage.
|
||||
- Update `Version`/`VersionRule` comments, the configuration contract's version and
|
||||
conversion sections, and stale consumer claims that all integer versions must
|
||||
be rewritten. Describe only behavior implemented by this stage.
|
||||
|
||||
Acceptance: a full load of `version: 4` with a configured rule and string-backed
|
||||
field succeeds and stores `"4"`; without the rule it still fails strict decoding.
|
||||
An unrelated integer-to-string field still fails. Existing accepted input remains
|
||||
unchanged. Both checking and decoding are implemented before this stage is done.
|
||||
|
||||
Validation: `go test . -run 'Version|Decode'`,
|
||||
`go test ./examples/consumers`, then the everyday checker.
|
||||
|
||||
Completion record: checker and load-local decoder support landed with strict
|
||||
no-rule and unrelated-field behavior retained. Focused package tests, consumer
|
||||
tests, and `./scripts/check.sh` pass with Go 1.26.7 on Linux. Stage 2 should expand
|
||||
the scalar, destination-type, and semantic-path boundary matrix.
|
||||
|
||||
## Stage 2 — Close scalar and path-boundary gaps
|
||||
|
||||
Status: not started. Depends on: Stage 1.
|
||||
|
||||
Scope: focused tests in `load_test.go`, `decode_test.go`, and relevant path/decoder
|
||||
helpers only where those tests expose defects. Avoid broad schema refactoring.
|
||||
|
||||
- Cover zero, negative integer, `-0`, a decimal identifier beyond int64 range,
|
||||
empty string, fractional/exponent numbers, booleans, and containers. Check parser
|
||||
rejection remains separate for non-JSON numeric spellings; reuse parser coverage.
|
||||
- Exercise a nonstandard nested version path, an escaped mapping key, contextual
|
||||
numeric path tokens, and sibling fields sharing a named string type. Prove
|
||||
conversion is path-scoped rather than descriptor-wide or name-based.
|
||||
- Cover named strings and pointer wrappers; show integer destinations and dynamic
|
||||
values retain ordinary types/range errors, while custom text codecs, durations,
|
||||
and secret-reference destinations do not gain integer coercion.
|
||||
- Verify unsupported kinds prevent the version callback, accepted integer input
|
||||
reaches application policy, and rejection by that policy prevents later hooks.
|
||||
Assert codes, semantic paths, and locations, not incidental message wording.
|
||||
|
||||
Acceptance: the target scalar/destination matrix is covered with lean table-driven
|
||||
tests, no silent overflow/rounding, and no coercion leakage to unrelated fields or
|
||||
loads. Expand a stage-1 test instead of duplicating the same behavior.
|
||||
|
||||
Validation: `go test . -run 'Version|Decode|Path'`, then the everyday checker.
|
||||
|
||||
## Stage 3 — Verify provenance and pipeline invariants
|
||||
|
||||
Status: not started. Depends on: Stage 2.
|
||||
|
||||
Scope: `explain_test.go`, `provenance_test.go`, import/profile/override and secret
|
||||
test owners as needed. Production changes only for demonstrated integration gaps.
|
||||
|
||||
- Add an end-to-end integer-version case asserting final string configuration,
|
||||
effective string rendering, original numeric history, and the existing decoding
|
||||
reconciliation event. Confirm locations, explicitness, and historical lookup.
|
||||
- Exercise equal-valued defaults and detached repeated loads/queries so conversion
|
||||
does not masquerade as resolution or mutate retained input.
|
||||
- Verify integer versions do not bypass fragment-version rejection, profile version
|
||||
protection, or CLI overlap checks; extend existing owners rather than recreating
|
||||
each suite. Include a nonstandard version path where practical.
|
||||
- Check sensitive original/effective version snapshots remain redacted and that
|
||||
the conversion adds no secret resolution or credential-bearing diagnostics.
|
||||
Use synthetic fixtures and existing lookup seams, never real credentials.
|
||||
|
||||
Acceptance: typed configuration, metadata, rendering, and explanation agree; the
|
||||
only intentional change is the version input/decoding exception. If inspection
|
||||
needs repair, retain original input rather than suppressing its history.
|
||||
|
||||
Validation: `go test . -run 'Version|Import|Profile|Override|Provenance|Explain|Secret'`,
|
||||
`go test -race ./...`, then the everyday checker.
|
||||
|
||||
## Stage 4 — Complete consumer evidence and release readiness
|
||||
|
||||
Status: not started. Depends on: Stage 3.
|
||||
|
||||
Scope: maintained Notarius-shaped examples/fixtures, consumer documentation,
|
||||
documentation consistency review, and validation handoff. No downstream app edits.
|
||||
|
||||
- Make integer `version: 4` and quoted `version: "4"` representative successful
|
||||
Notarius-shaped inputs with the same string-backed application result. Configure
|
||||
the example's application checker consistently; do not bypass version policy.
|
||||
- Retain negative examples for unsupported application versions and non-integer
|
||||
version kinds, with stable diagnostic codes/paths. Verify unrelated dynamic
|
||||
option integers and runtime conversions remain unchanged.
|
||||
- Audit current documentation and Go examples for string-only claims. Keep
|
||||
historical release notes, extraction records, and ADR rationale historical;
|
||||
repair navigation without rewriting historical decisions. Transfer completed
|
||||
target descriptions from the roadmap to canonical owners.
|
||||
- Run the full release checker using the command below (or qualifying cached
|
||||
toolchains selected under the release runbook). Report actual versions and results.
|
||||
- Hand off the compatibility assessment and remaining release work. Select an
|
||||
unused version with the maintainer; create version-matched notes only for a real
|
||||
candidate. A changed rejection contract must be reviewed explicitly; do not reuse
|
||||
the v0.1.1-only policy exception.
|
||||
|
||||
Acceptance: complete consumer regression and all available automated gates pass;
|
||||
no current documentation promises unimplemented behavior. Mark implementation
|
||||
complete separately from publication. Exact-commit Linux/macOS checks, candidate
|
||||
commit/push, release-note approval, roadmap retirement, tagging, and clean consumer
|
||||
retrieval follow the [release procedure](../release.md) when separately authorized.
|
||||
Unavailable macOS evidence is a release blocker, not permission to waive the gate.
|
||||
|
||||
Validation: `go test ./examples/consumers`, `go test . -run '^Example'`, the everyday
|
||||
checker, and the full release checker:
|
||||
|
||||
```sh
|
||||
CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.5 \
|
||||
CONFIGKIT_NEWER_TOOLCHAIN=go1.26.7 \
|
||||
./scripts/release-check.sh
|
||||
```
|
||||
|
||||
Rerun exact-commit release checks after
|
||||
any later candidate or roadmap-retirement changes.
|
||||
142
docs/roadmap/integer-schema-versions.md
Normal file
142
docs/roadmap/integer-schema-versions.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# String and integer schema versions
|
||||
|
||||
Status: planned; not implemented. This document owns the intended end state.
|
||||
The [implementation plan](implementation.md) owns sequencing and
|
||||
progress. Current behavior remains defined by the [configuration contract](../config.md).
|
||||
|
||||
## Motivation and scope
|
||||
|
||||
Notarius historically declares `version: 4`. ConfigKit currently rejects this at
|
||||
the version-check boundary and independently rejects assigning an integer to a Go
|
||||
string during decoding. Consumers should be able to retain an integer version
|
||||
declaration while using a string for application version comparison and storage.
|
||||
|
||||
Support this through the existing `VersionRule`, not a new parser mode, global
|
||||
coercion option, public union type, or application-side YAML rewrite. Applications
|
||||
continue to decide which version identifiers are supported. No downstream
|
||||
repository changes or automatic schema migrations are included.
|
||||
|
||||
## Target contract
|
||||
|
||||
The following is intended future behavior, not a description of today's release.
|
||||
|
||||
### Version checking
|
||||
|
||||
Only the semantic path designated by `Definition.Version.Path` receives version
|
||||
handling. There is no special behavior based on a field being named `version`.
|
||||
An absent rule leaves all existing decoding behavior unchanged.
|
||||
|
||||
| Input at the configured path | Checker input |
|
||||
| --- | --- |
|
||||
| Absent | `Version{Present: false}` |
|
||||
| Explicit null | `Version{Present: true, Null: true}` |
|
||||
| String, including empty string | Present, non-null; `Text` preserves the string |
|
||||
| Number with integer syntax | Present, non-null; `Text` preserves the validated decimal spelling |
|
||||
| Fractional/exponent number, boolean, mapping, or sequence | `CodeVersionType`; checker not called |
|
||||
|
||||
Integer syntax means the existing strict decimal integer grammar
|
||||
`^-?(0|[1-9][0-9]*)$`. Thus `4` and `"4"` both supply text `"4"`.
|
||||
`-0` supplies `"-0"`; do not silently canonicalize it to `"0"`. Negative and zero
|
||||
identifiers are structurally accepted; application policy may reject them.
|
||||
`4.0` and `4e0` are not integer syntax, even though their mathematical values are
|
||||
integral. Quoted versions such as `"4.0"` remain strings and remain accepted.
|
||||
The parser's existing rejection of leading zeroes, hexadecimal, leading plus,
|
||||
and other unsupported scalar spellings stays unchanged.
|
||||
|
||||
Use the validated number text directly, without conversion through `int`, `int64`,
|
||||
or floating point. Textual versions need no machine-integer range limit beyond
|
||||
existing input resource limits. Numeric Go destinations retain their normal range
|
||||
checks. Preserve the public `Version` and `VersionRule` shapes: no new kind flag
|
||||
or opt-in field is needed.
|
||||
|
||||
Checking remains before selected-profile application, defaults, typed decoding,
|
||||
resolution, and secret lookup. Callback warnings, errors, absent/null handling,
|
||||
safe diagnostics, and source attribution retain their current behavior.
|
||||
|
||||
### Typed decoding
|
||||
|
||||
At exactly the configured version path, an integer node may decode into a plain
|
||||
Go string descriptor, including a named string without custom text decoding and
|
||||
pointer wrappers around those types. The resulting string is the same text the
|
||||
version checker receives. This permits both integer and quoted version YAML when
|
||||
the application stores its version as a string.
|
||||
|
||||
Other destination types retain ordinary decoding: an integer field can still
|
||||
decode integer YAML, but quoted strings are not coerced into integers. Dynamic
|
||||
`any` retains its usual scalar types and numeric bounds. This feature does not
|
||||
force every destination to become a string. Custom text codecs, durations, and
|
||||
secret-reference types gain no integer conversion, even if their path is the
|
||||
configured version path. Missing/null values and strict unknown-field validation
|
||||
also retain normal typed semantics; passing the checker does not bypass decoding.
|
||||
|
||||
Unrelated fields, sibling fields with the same Go type, ancestors, descendants,
|
||||
and fields merely named `version` receive no coercion. Semantic path matching must
|
||||
respect escaped keys and contextual numeric tokens using existing path machinery.
|
||||
Do not alter pointer-versus-key/index distinctions by comparing field names alone.
|
||||
|
||||
### Composition, inspection, and secrets
|
||||
|
||||
Version ownership remains unchanged: imported fragments cannot supply the
|
||||
configured version, profiles cannot replace it or a covering ancestor, and CLI
|
||||
overrides cannot overlap it. No conversion may hide a composition conflict or
|
||||
weaken those checks.
|
||||
|
||||
Keep the parsed input and retained history numeric when integer syntax was used.
|
||||
For a Go string destination, typed projection and effective YAML show a string;
|
||||
historical explanation should expose the existing decoding reconciliation event
|
||||
between numeric input and string output. Presence, source locations, origins,
|
||||
historical paths, and detached ownership remain intact. Do not represent this
|
||||
conversion as application resolution or invent a synthetic source.
|
||||
|
||||
Existing sensitivity classification and redaction apply to both original and
|
||||
decoded snapshots. Conversion performs no environment access, invokes no codecs
|
||||
that previously required strings, and introduces no raw-value diagnostic text.
|
||||
|
||||
## Intended architecture
|
||||
|
||||
The current entry points are `checkVersion` in `validation.go`,
|
||||
`decodeLoadInput` in `load_result.go`, and `decodeConfiguration`/`decodeValue` in
|
||||
`decode.go`. The lifecycle in `load.go` already supplies the definition to both
|
||||
phases; retain its ordering.
|
||||
|
||||
1. Share a small private integer-version text predicate/extractor between checking
|
||||
and decoding, reusing the existing integer grammar. Do not extend the YAML
|
||||
adapter or expose its node types.
|
||||
2. Carry an optional, per-load version-path capability into typed decoding through
|
||||
private decode context. Follow it through recursive decoding, including
|
||||
pointer, map, and dynamic-structure paths. Default private decoder callers
|
||||
should remain strict unless they explicitly receive that capability.
|
||||
3. Apply the exception only when the current semantic path matches the configured
|
||||
path, the source is an integer number, and the destination descriptor is a
|
||||
plain string. All other cases use existing dispatch and errors.
|
||||
4. Let normal typed projection produce the final string representation. Retain
|
||||
original input for provenance and explanation; avoid rewriting an assembled
|
||||
document or cloning the entire tree solely to convert one scalar.
|
||||
|
||||
Do not attach path-specific conversion to a shared type descriptor: descriptors
|
||||
can be reused by unrelated fields of the same Go type. Keep the capability local
|
||||
to one decoding operation, not global state or a public schema extension. Exact
|
||||
private context names/signatures are implementation choices, not API commitments.
|
||||
|
||||
## Compatibility and completion
|
||||
|
||||
Existing accepted strings keep their meaning. Newly accepted integers intentionally
|
||||
change the previous `version.type` rejection contract and cause application version
|
||||
checkers to run on inputs they previously never saw. Release review must account
|
||||
for that, rather than describing the change as entirely behavior-neutral. A
|
||||
pre-v1 minor release is the conservative choice under the
|
||||
[release policy](../policy/release.md); this roadmap chooses no tag and grants no
|
||||
patch-policy exception or publication authority.
|
||||
|
||||
Completion requires end-to-end integer/string equivalence for a string-backed
|
||||
Notarius-shaped configuration, strict rejection outside the version exception,
|
||||
correct original/effective inspection, and retained version-ownership and secret
|
||||
safety guarantees. Update Go reference comments, the configuration contract, and
|
||||
consumer migration guidance as their behavior lands. Historical ADR rationale and
|
||||
published release notes remain unchanged. If an accepted ADR actually conflicts
|
||||
with the implementation decision, supersede it using the documentation policy;
|
||||
do not rewrite its accepted rationale.
|
||||
|
||||
Once implemented, move target contracts to their canonical owners and replace
|
||||
duplicated roadmap text with links. Retire completed roadmap/plan documents during
|
||||
release preparation, preserving the execution record in Git history.
|
||||
@@ -124,9 +124,9 @@ func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "integer schema version",
|
||||
name: "unsupported integer schema version",
|
||||
load: func() error { _, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml")); return err },
|
||||
code: configkit.CodeVersionType, path: "/version",
|
||||
code: configkit.CodeVersionError, path: "/version",
|
||||
},
|
||||
{
|
||||
name: "module shorthand",
|
||||
|
||||
4
load.go
4
load.go
@@ -51,12 +51,14 @@ type Resolution struct {
|
||||
}
|
||||
|
||||
// VersionRule declares the input path and application checker for a schema version.
|
||||
// At that path, string and decimal-integer input is supplied as Version.Text.
|
||||
type VersionRule struct {
|
||||
Path Path
|
||||
Check func(Version, *Validation) error
|
||||
}
|
||||
|
||||
// Version preserves the absent, null, and string states of a schema-version input.
|
||||
// Version preserves the absent, null, and textual states of a schema-version input.
|
||||
// Text is the exact spelling of accepted string or decimal-integer input.
|
||||
type Version struct {
|
||||
Present bool
|
||||
Null bool
|
||||
|
||||
@@ -22,7 +22,11 @@ func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
||||
if definition.Defaults != nil {
|
||||
defaults = reflect.ValueOf(definition.Defaults())
|
||||
}
|
||||
decoded, decodeIssues := decodeConfiguration(schema, defaults, input.document)
|
||||
context := decodeContext{}
|
||||
if definition.Version != nil {
|
||||
context.versionPath = &definition.Version.Path
|
||||
}
|
||||
decoded, decodeIssues := decodeConfigurationWithContext(schema, defaults, input.document, context)
|
||||
if len(decodeIssues) != 0 {
|
||||
diagnostics := make([]Diagnostic, len(decodeIssues))
|
||||
for i, issue := range decodeIssues {
|
||||
|
||||
96
load_test.go
96
load_test.go
@@ -72,7 +72,9 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
||||
{name: "no source absent", source: NoSource(), want: Version{}},
|
||||
{name: "null", source: Bytes("null.yml", []byte("schema_version: null\n")), want: Version{Present: true, Null: true}, wantLine: 1},
|
||||
{name: "string", source: Bytes("string.yml", []byte("schema_version: app.v1\n")), want: Version{Present: true, Text: "app.v1"}, wantLine: 1},
|
||||
{name: "integer", source: Bytes("integer.yml", []byte("schema_version: 1\n")), wantErr: CodeVersionType},
|
||||
{name: "integer", source: Bytes("integer.yml", []byte("schema_version: 1\n")), want: Version{Present: true, Text: "1"}, wantLine: 1},
|
||||
{name: "boolean", source: Bytes("boolean.yml", []byte("schema_version: true\n")), wantErr: CodeVersionType},
|
||||
{name: "sequence", source: Bytes("sequence.yml", []byte("schema_version: []\n")), wantErr: CodeVersionType},
|
||||
{name: "returned error", source: Bytes("return.yml", []byte("{}\n")), wantErr: CodeVersionHookFailed, returnOnly: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -103,7 +105,7 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if test.wantErr == CodeVersionType && called {
|
||||
t.Fatal("checker ran for a non-string version")
|
||||
t.Fatal("checker ran for an unsupported version kind")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -124,6 +126,96 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type stringConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
type pointerConfig struct {
|
||||
Version *string `yaml:"version"`
|
||||
}
|
||||
versionPath := Keys("version")
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
source string
|
||||
load func(Source, *VersionRule) (string, error)
|
||||
}{
|
||||
{name: "integer into string", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Config.Version, nil
|
||||
}},
|
||||
{name: "string into string", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Config.Version, nil
|
||||
}},
|
||||
{name: "integer into pointer", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Config.Version == nil {
|
||||
t.Fatal("version pointer is nil")
|
||||
}
|
||||
return *result.Config.Version, nil
|
||||
}},
|
||||
{name: "string into pointer", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Config.Version == nil {
|
||||
t.Fatal("version pointer is nil")
|
||||
}
|
||||
return *result.Config.Version, nil
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var checked Version
|
||||
rule := &VersionRule{Path: versionPath, Check: func(version Version, _ *Validation) error {
|
||||
checked = version
|
||||
return nil
|
||||
}}
|
||||
got, err := test.load(Bytes("version.yml", []byte(test.source)), rule)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "4" || checked != (Version{Present: true, Text: "4"}) {
|
||||
t.Fatalf("value = %q, checked = %#v", got, checked)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[stringConfig]{}); result != nil {
|
||||
t.Fatalf("load without rule = %#v, %v", result, err)
|
||||
} else {
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeDecodeType {
|
||||
t.Fatalf("load without rule error = %v", err)
|
||||
}
|
||||
}
|
||||
type unrelatedConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
Label string `yaml:"label"`
|
||||
}
|
||||
rule := &VersionRule{Path: versionPath, Check: func(Version, *Validation) error { return nil }}
|
||||
if result, err := Load(Bytes("version.yml", []byte("version: 4\nlabel: 5\n")), Options[unrelatedConfig]{Definition: Definition[unrelatedConfig]{Version: rule}}); result != nil {
|
||||
t.Fatalf("unrelated coercion = %#v, %v", result, err)
|
||||
} else {
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeDecodeType || loadError.Diagnostics()[0].Path.String() != "/label" {
|
||||
t.Fatalf("unrelated coercion error = %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package configkit
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
@@ -200,12 +201,18 @@ func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
||||
version.Null = true
|
||||
case document.String:
|
||||
version.Text = node.Text
|
||||
case document.Number:
|
||||
if isIntegerVersion(node) {
|
||||
version.Text = node.Text
|
||||
break
|
||||
}
|
||||
fallthrough
|
||||
default:
|
||||
location, _ := nearestDocumentLocation(root, rule.Path)
|
||||
primary := documentLocationPointer(location)
|
||||
return []Diagnostic{{
|
||||
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
|
||||
Message: "schema version must be a string or null", Primary: primary,
|
||||
Message: "schema version must be a string, integer, or null", Primary: primary,
|
||||
}}
|
||||
}
|
||||
}
|
||||
@@ -226,6 +233,33 @@ func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
||||
return canonicalDiagnostics(state.diagnostics)
|
||||
}
|
||||
|
||||
func isIntegerVersion(node *document.Node) bool {
|
||||
return node != nil && node.Kind == document.Number && integerPattern.MatchString(node.Text)
|
||||
}
|
||||
|
||||
func pathMatches(configured *Path, current Path) bool {
|
||||
if configured == nil || !configured.valid() || !current.valid() || len(configured.segments) != len(current.segments) {
|
||||
return false
|
||||
}
|
||||
for i, expected := range configured.segments {
|
||||
actual := current.segments[i]
|
||||
if expected.kind == pathSegmentParsed {
|
||||
if actual.kind == pathSegmentIndex {
|
||||
if expected.key != strconv.Itoa(actual.index) {
|
||||
return false
|
||||
}
|
||||
} else if expected.key != actual.key {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if expected.kind != actual.kind || expected.key != actual.key || expected.index != actual.index {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func documentLocationPointer(location document.Location) *Location {
|
||||
if !locationKnown(location) {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user