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

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