Support integer schema versions through typed decoding

This commit is contained in:
2026-09-12 00:25:54 +00:00
parent b3e7b62119
commit f5bbc2be5d
11 changed files with 484 additions and 38 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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.

View 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.

View File

@@ -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",

View File

@@ -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

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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