Files
ConfigKit/docs/roadmap/correctness.md

192 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Functional Correctness Audit
Reviewed revision: `47944d22d1ad45a7f537780effb3d05f998eef14`
(Update ReleaseKit to v0.4.2).
Audit date: 2026-10-25.
## Scope And Evidence
This audit reviewed the root facade and its engine handoffs, source/discovery
and filesystem handling, composition and collection rules, inheritance, typed
decoding and ownership, application callbacks, secrets, metadata, explanation,
and effective-value comparison against the configuration contract and relevant
implementation/tests. It was a source-level functional review with targeted
diagnostics, not an exhaustive proof or a performance/security certification.
The existing development check (`./scripts/check.sh`) and full race-enabled
suite (`go test -race ./...`) passed using Go 1.26.10 on Linux amd64. Standalone
public-API probes outside the repository reproduced the findings below. No
production code or repository tests were added or changed. No native macOS or
Windows validation was performed, and no new release gates are proposed.
## 1. Pointer-Only Recursive Types Can Overflow The Stack Or Hang
Status: open. Priority: medium. Category: schema/decoder termination.
**Relevant code.** `schemaBuilder.describe` in
[schema.go](../../internal/engine/schema.go), lines 119–140, caches a placeholder
before describing pointer elements. A legal Go type such as `type Loop *Loop`
therefore produces a pointer descriptor whose element is itself.
`decodeValue` in [decode.go](../../internal/engine/decode.go), lines 93–106,
recursively unwraps pointers without consuming the input node. The pointer
unwrapping loops in `resolveSchemaPath` (`schema.go`, lines 347–350 and 387–390)
also assume that a non-pointer descriptor will eventually be reached.
**Reproduction.** With the public package imported as `configkit`:
```go
type Loop *Loop
type Config struct {
Value Loop `yaml:"value"`
}
configkit.Load[Config](configkit.Bytes("input", []byte("value: {}\n")))
```
This does not return a decode error. It repeatedly calls `decodeValue` with the
same input and descriptor until the process reports a fatal stack overflow.
A disposable probe using `debug.SetMaxStack(64 << 10)` demonstrated the failure
without allowing the process to grow a large stack. Explicit `value: null`
loads successfully. With that null input, adding a resolver that calls
`Normalize` on `/value` with `Loop(nil)` instead hangs in `resolveSchemaPath`;
the separate diagnostic process was stopped by a three-second timeout.
**Expected behavior.** Incompatible non-null input must fail with a safe
diagnostic, and schema-path resolution must terminate. The
[schema contract](../config.md#application-schema-binding) supports recursive
types with acyclic values; the existing explicit pointer-only restriction is
for inline fields, not all pointer fields. This is not a request to recover
application panics or reject ordinary recursive structs.
**Potential remediation.** Detect repeated descriptors while unwrapping
pointer-only chains in decoding and schema-path traversal. Preserve supported
nil/null values and finite acyclic values, return an appropriate decode error
for an incompatible non-null node, and reject descendants that cannot be
addressed. Review analogous pointer-unwrapping helpers for the same assumption.
A blanket schema rejection of pointer-only recursive types would instead be a
contract decision, since it would also reject currently valid nil values; do
not introduce it or an arbitrary pointer-depth cap merely to stop the loop.
## 2. Failed Dynamic Schema Construction Leaves A Reusable Partial Descriptor
Status: open. Priority: medium. Category: error handling and shared operation state.
**Relevant code.** `valueOperation.dynamicDescriptor` in
[value_operation.go](../../internal/engine/value_operation.go), lines 36–48,
passes the load's existing descriptor cache directly to a new schema builder.
`schemaBuilder.describe` in [schema.go](../../internal/engine/schema.go), lines
119–140 and 167–191, caches container placeholders but returns child errors
without removing those placeholders. A subsequent lookup returns the partial
descriptor as if construction had succeeded.
**Reproduction.** Use a configuration with `Value any` and this dynamic type:
```go
type Invalid struct {
Channel chan int `yaml:"channel"`
}
```
In one resolver, execute these two assignments in order:
```go
configkit.Normalize(r, configkit.Keys("value"), &c.Value,
any((*Invalid)(nil)), "first unsupported value")
configkit.Normalize(r, configkit.Keys("value"), &c.Value,
any(&Invalid{}), "second unsupported value")
```
The first helper correctly records an invalid-value diagnostic because channels
are unsupported. It nevertheless leaves the `*Invalid` descriptor cached with
a nil element. The second helper reuses it, reaches projection with that nil
element, and panics with a nil-pointer dereference instead of returning a
`LoadError`. This was reproduced through ordinary public `Load` and `Normalize`;
the application callback itself does not panic.
**Expected behavior.** Unsupported dynamic/helper values must produce safe
schema/resolution diagnostics under the
[typed projection](../config.md#typed-projection-and-ownership) and
[resolution contracts](../config.md#recorded-application-resolution).
One reported validation failure must not corrupt the descriptor cache used by
later operations in the same callback.
**Potential remediation.** Make dynamic descriptor construction transactional:
stage newly discovered descriptors and commit them only after a successful
build, or track and roll back all cache additions belonging to a failed build.
Account for recursive descriptors that refer to other partially built types,
not just the immediate failed container. Preserve previously valid descriptors
and the load-local cache ownership. A nil guard in projection alone would not
repair invalid cache entries being treated as successful schema descriptions.
## 3. Cycle Detection Rejects Acyclic Slice Views Sharing A Starting Address
Status: open. Priority: medium. Category: supported-value correctness.
**Relevant code.** `valueVisit` and `visitFor` in
[value_operation.go](../../internal/engine/value_operation.go), lines 20–24
and 97–116, identify a slice by its type, kind, and starting pointer, but not its
length. Both reflective projection/cloning and the parser-neutral value builder
in [value.go](../../internal/engine/value.go) use that identity for active-path
cycle detection.
**Reproduction.** This value is finite and acyclic: its nested one-element view
contains only the string, not the enclosing two-element view.
```go
shared := []any{"leaf", nil}
shared[1] = shared[:1]
```
Place it at `data` in a `Mapping` source, return it from typed defaults for
`Data []any`, or supply it as a CLI override. Observed results at `/data/1` were,
respectively, `source.invalid`, `schema.cycle`, and `override.invalid_value`.
The equivalent independently allocated value
`[]any{"leaf", []any{"leaf"}}` loads and renders successfully. A true
self-referential slice was also checked and remains correctly rejected.
**Expected behavior.** The
[mapping source](../config.md#sources),
[projection/ownership](../config.md#typed-projection-and-ownership), and
[override](../config.md#explicit-cli-overrides) contracts permit supported
acyclic values. Shared backing storage alone is not a cycle, and detaching it
does not require preserving alias identity in the returned configuration.
**Potential remediation.** Include the visible slice extent (its length) in the
active-visit identity, or use an equivalent identity that distinguishes these
views. Preserve detection of actual self/mutual cycles, empty/nil distinctions,
and independent ownership of retained values. Cover the shared helper's source,
override, projection, and cloning callers rather than fixing only one entry point.
## 4. Callback Contexts Remain Active After Panic Unwinding
Status: open. Priority: low. Category: callback-lifetime contract.
**Relevant code.** `runResolution` in
[resolution.go](../../internal/engine/resolution.go), lines 283–291, and
`runValidation`/`reportVersion` in
[validation.go](../../internal/engine/validation.go), lines 99–106 and 250–257,
set `state.active = false` only after the application callback returns normally.
The root facade forwards the context without an additional lifetime guard.
**Reproduction.** Retain the supplied context in a resolver, semantic validator,
or version checker, then panic in that callback. Recover the application panic
outside `Load` and invoke the retained context. Public-API probes showed that
`Resolution.IsExplicit` still returns a value and `Validation.Warning` still
accepts a report for both validation and version contexts; none produces the
documented closed-context panic.
**Expected behavior.** The
[resolution](../config.md#recorded-application-resolution) and
[validation](../config.md#semantic-validation) contracts make these contexts
callback-scoped and require retained contexts to reject use after callback exit.
Application panics remain programming failures and should propagate unchanged;
closing the context during unwinding does not require ConfigKit to recover them
or return a partial result. This finding concerns only the lifetime guard after
an application chooses to recover its own panic.
**Potential remediation.** Deactivate the context with a defer scoped to each
callback invocation, retaining immediate deactivation on normal return as well
as on panic unwinding. Do not leave the context active through later projection
or metadata work, and do not convert or suppress the callback panic. Apply the
same lifetime handling to resolution, semantic validation, and version checks.