diff --git a/decode.go b/decode.go index 427e1a0..22ee500 100644 --- a/decode.go +++ b/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 diff --git a/docs/config.md b/docs/config.md index 249fadb..20185ee 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 diff --git a/docs/consumers/README.md b/docs/consumers/README.md index 44cf5c1..8ebbadc 100644 --- a/docs/consumers/README.md +++ b/docs/consumers/README.md @@ -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 diff --git a/docs/development.md b/docs/development.md index 4dc8fcd..3b41c5c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md new file mode 100644 index 0000000..0f2fc0d --- /dev/null +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/docs/roadmap/integer-schema-versions.md b/docs/roadmap/integer-schema-versions.md new file mode 100644 index 0000000..f12af47 --- /dev/null +++ b/docs/roadmap/integer-schema-versions.md @@ -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. diff --git a/examples/consumers/consumers_test.go b/examples/consumers/consumers_test.go index 255d913..3f1e164 100644 --- a/examples/consumers/consumers_test.go +++ b/examples/consumers/consumers_test.go @@ -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", diff --git a/load.go b/load.go index cf762f6..9e93e3c 100644 --- a/load.go +++ b/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 diff --git a/load_result.go b/load_result.go index 006e27f..41c5efb 100644 --- a/load_result.go +++ b/load_result.go @@ -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 { diff --git a/load_test.go b/load_test.go index 06a7cb6..c455c26 100644 --- a/load_test.go +++ b/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() diff --git a/validation.go b/validation.go index 0d42a8b..bc7ee4e 100644 --- a/validation.go +++ b/validation.go @@ -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