Record public facade boundary

This commit is contained in:
2026-09-12 15:56:11 +00:00
parent eb7de8e40a
commit f81def9365
7 changed files with 202 additions and 10 deletions

View File

@@ -0,0 +1,61 @@
# ADR 0006: Keep the public facade root-owned
## Status
Accepted. This records a design decision, not implementation completion.
## Date
2026-09-12
## Context
The root `configkit` package currently combines the supported consumer API with
the loading, decoding, inspection, and rendering machinery. That makes ordinary
consumer navigation harder and risks exposing implementation details through
future public declarations. Moving files alone would not preserve the identity,
method sets, callbacks, ownership rules, or reflection-sensitive behavior of
the root's public types.
## Decision
Keep `configkit` as the sole supported consumer import path and keep all public
named types defined there. When lifecycle machinery is extracted, it will live
in a cohesive `internal/engine`; the root facade may hold private engine state
and forward deliberately selected methods. The dependency direction is root to
engine and its supporting internal packages. Internal production packages do
not import the root package.
The facade will use root-owned wrappers and explicit private conversions. It
will not use public aliases to engine types, anonymous embedding that promotes
engine methods, public engine types in signatures or fields, or public bridge
methods. Existing document and YAML boundaries remain intact.
The migration may use a temporary internal candidate facade while the unchanged
root remains the only production entry point. That duplication is a bounded
relocation mechanism, not a second supported package, feature path, or runtime
selection mechanism. The final cutover removes the temporary candidate and old
root implementation together.
The [architecture policy](../policy/architecture.md#public-api-and-dependency-direction)
owns the binding boundary. The [public-facade roadmap](../roadmap/public.md)
owns the intended end state and its implementation plan owns progress.
## Alternatives considered
- Alias root types to engine types: reduces forwarding, but makes the engine's
exported method set and type ownership part of the consumer API.
- Keep all machinery in the root package: avoids migration work, but leaves the
public boundary difficult to navigate and easy to expand accidentally.
- Introduce a generic adapter or registration framework: could reduce some
handwritten conversions, but adds machinery and obscures the ownership rules
this boundary needs to preserve.
## Consequences
The refactor carries explicit forwarding and conversion work, especially for
callbacks, detached records, source ownership, frozen inspection, and the
exact-type secret bridge. Public compatibility must be verified by behavior and
external-package evidence rather than inferred from package names. Internal
exports remain implementation surfaces, and the completed architecture will
have one engine and one root facade.

View File

@@ -30,6 +30,7 @@ Accepted decisions:
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md) - [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md) - [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md) - [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
- [Keep the public facade root-owned](adr/0006-public-facade.md)
For cross-cutting changes, follow every applicable row. Completed implementation For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current and remediation plans are preserved in Git history at `cb5b435`; use current
@@ -47,9 +48,10 @@ records are preserved in Git history at `244104f`. The
[configuration contract](config.md#application-schema-versions) owns current behavior. [configuration contract](config.md#application-schema-versions) owns current behavior.
The completed [source-origins during resolution record](roadmap/source-origins-during-resolution.md) The completed [source-origins during resolution record](roadmap/source-origins-during-resolution.md)
links to the current contract and evidence. Its staged implementation handoffs remain links to the current contract and evidence. Its completed implementation handoffs
in [implementation.md](roadmap/implementation.md) until release preparation retires are preserved at `28e7fc5:docs/roadmap/implementation.md`; inspect them with
completed roadmap material. `git show 28e7fc5:docs/roadmap/implementation.md` until release preparation
retires the completed roadmap material.
## Baseline validation ## Baseline validation

View File

@@ -76,6 +76,18 @@ Do not expose parser-specific nodes, errors, or extension hooks through the
public API. Parser integration belongs behind a private boundary, as recorded public API. Parser integration belongs behind a private boundary, as recorded
in [ADR 0002](../adr/0002-own-yaml-semantics.md). in [ADR 0002](../adr/0002-own-yaml-semantics.md).
The root `configkit` package is the sole supported consumer import path. Public
named types stay root-owned and may hold private delegated implementation state;
their exported signatures, fields, and method sets must not expose internal
types or acquire internal methods through anonymous embedding. Root production
code may depend inward on a cohesive engine and its supporting internal packages,
but internal production packages must not import the root. Explicit forwarding
and local conversion are acceptable where they preserve type identity,
ownership, callback lifetime, and inspection behavior. The accepted
root-wrapper boundary and its migration rationale are recorded in
[ADR 0006](../adr/0006-public-facade.md); its implementation remains tracked by
the public-facade roadmap.
Separate input access, document processing, composition, decoding, metadata, and Separate input access, document processing, composition, decoding, metadata, and
rendering responsibilities without prescribing an interface or package for each. rendering responsibilities without prescribing an interface or package for each.
Dependencies must preserve those boundaries; package splits and abstractions Dependencies must preserve those boundaries; package splits and abstractions

View File

@@ -181,6 +181,35 @@ distinguish decision from implementation, and documentation links pass.
Run `go test . -run 'LocalDocumentationLinks|DependencyImportBoundary|Example|Facade'` Run `go test . -run 'LocalDocumentationLinks|DependencyImportBoundary|Example|Facade'`
and the everyday check. No engine migration in this stage. and the everyday check. No engine migration in this stage.
### Completion
Status: complete (2026-09-12).
Baseline behavior was verified at `28e7fc5f690b36c60e4d03586afcff00fb1dee90`;
this plan was introduced by `eb7de8e40ab944f022a2b6984f4d4e0086f1484d`. The
complete root surface was inventoried with `go doc -all .`: lifecycle and generic
entry points; root value/input types (`Path`, `Source`, `Secret`); callback and
option records; metadata, provenance, diagnostics, history, and rendering
records; typed errors and bootstrap reports; and the scalar enum families. The
inventory also covered receiver methods, generic instantiations, and relevant
interfaces (`error`, `fmt.Formatter`, `fmt.Stringer`, `json.Marshaler`, and
`io.WriterTo`). Existing behavioral tests remain the source of detailed API
semantics; `facade_compatibility_test.go` supplies compact external-package
coverage for the migration boundary.
Added ADR 0006 and the architecture rule for root-owned public wrappers and
inward-only implementation dependencies. They describe the accepted target, not
an already-completed extraction. Repaired the development guide, source-origins
record, and remediation record to identify the old provenance plan by its Git
revision instead of linking to this plan. No engine or lifecycle migration was
introduced.
Validation on Linux with Go 1.26.7 passed: the focused documentation,
import-boundary, executable-example, and facade checks, followed by
`./scripts/check.sh`. There were no deviations. The next handoff copies the
unchanged root implementation into the bounded internal relocation candidate;
it must retain the documented public behavior while proving engine independence.
## Stage 2 — Establish the internal relocation candidate ## Stage 2 — Establish the internal relocation candidate
Status: not started. Status: not started.

View File

@@ -22,8 +22,9 @@ displaced/default paths that the view cannot query. This adds allocation and cop
work proportional to retained history even for a resolver that never calls the work proportional to retained history even for a resolver that never calls the
new methods. No wall-clock or memory threshold was measured or is asserted here. new methods. No wall-clock or memory threshold was measured or is asserted here.
The Stage 5 completion record in [implementation.md](implementation.md) also states The historical completion record at
that the view "does not retain history"; the constructor contradicts that claim. `28e7fc5:docs/roadmap/implementation.md` also states that the view "does not
retain history"; the constructor contradicts that claim.
### Correction ### Correction

View File

@@ -38,11 +38,13 @@ confinement check.
## Evidence and handoff ## Evidence and handoff
The completed staged record in [implementation.md](implementation.md) maps all The completed staged record is preserved at
twelve acceptance criteria to focused lifecycle, provenance, ownership, consumer, `28e7fc5:docs/roadmap/implementation.md`; retrieve it with
explanation, and secret tests. The final implementation commit is expected to run `git show 28e7fc5:docs/roadmap/implementation.md`. It maps all twelve acceptance
the source checker and the documented two-toolchain gate before any release criteria to focused lifecycle, provenance, ownership, consumer, explanation, and
candidate is selected. secret tests. The final implementation commit is expected to run the source
checker and the documented two-toolchain gate before any release candidate is
selected.
Publication remains a maintainer decision. Release preparation must choose the Publication remains a maintainer decision. Release preparation must choose the
version, write a version-matched note, retire this completed roadmap with link version, write a version-matched note, retire this completed roadmap with link

View File

@@ -0,0 +1,85 @@
package configkit_test
import (
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
var (
_ error = (*configkit.LoadError)(nil)
_ fmt.Formatter = configkit.Secret{}
_ fmt.Stringer = configkit.Path{}
_ json.Marshaler = configkit.Secret{}
_ io.WriterTo = (*configkit.SecretsReport)(nil)
)
type facadeCompatibilityConfig struct {
Value string `yaml:"value"`
Derived string `yaml:"derived"`
}
func TestFacadeCompatibility(t *testing.T) {
t.Parallel()
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
for _, candidate := range []struct {
name string
type_ reflect.Type
}{
{"Path", reflect.TypeFor[configkit.Path]()},
{"Source", reflect.TypeFor[configkit.Source]()},
{"Secret", reflect.TypeFor[configkit.Secret]()},
{"Definition", reflect.TypeFor[configkit.Definition[facadeCompatibilityConfig]]()},
{"Options", reflect.TypeFor[configkit.Options[facadeCompatibilityConfig]]()},
{"Result", reflect.TypeFor[configkit.Result[facadeCompatibilityConfig]]()},
} {
if candidate.type_.PkgPath() != packagePath {
t.Errorf("%s belongs to %q, want %q", candidate.name, candidate.type_.PkgPath(), packagePath)
}
}
constructed := configkit.Options[facadeCompatibilityConfig]{
Definition: configkit.Definition[facadeCompatibilityConfig]{},
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
Overrides: []configkit.Override{{
Path: configkit.Keys("value"),
Value: "override",
Flag: "--value",
}},
}
if got := constructed.Overrides[0].Path.String(); got != "/value" {
t.Fatalf("constructed override path = %q, want /value", got)
}
publicResult := configkit.Result[facadeCompatibilityConfig]{Config: facadeCompatibilityConfig{Value: "constructed"}}
if publicResult.Config.Value != "constructed" {
t.Fatal("constructed result lost its public configuration")
}
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
Definition: configkit.Definition[facadeCompatibilityConfig]{
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived, configuration.Value+" default", "derive value")
configkit.Normalize(resolution, configkit.Keys("value"), &configuration.Value, "normalized "+configuration.Value, "normalize value")
return nil
},
},
})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if result.Config != (facadeCompatibilityConfig{Value: "normalized input", Derived: "input default"}) {
t.Fatalf("Load() Config = %#v", result.Config)
}
_, err = configkit.Load[facadeCompatibilityConfig](configkit.Bytes("invalid.yml", []byte("value: [\n")))
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("Load() error = %T, want *configkit.LoadError", err)
}
}