Record public facade boundary
This commit is contained in:
61
docs/adr/0006-public-facade.md
Normal file
61
docs/adr/0006-public-facade.md
Normal 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.
|
||||
@@ -30,6 +30,7 @@ Accepted decisions:
|
||||
- [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)
|
||||
- [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
|
||||
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.
|
||||
|
||||
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
|
||||
in [implementation.md](roadmap/implementation.md) until release preparation retires
|
||||
completed roadmap material.
|
||||
links to the current contract and evidence. Its completed implementation handoffs
|
||||
are preserved at `28e7fc5:docs/roadmap/implementation.md`; inspect them with
|
||||
`git show 28e7fc5:docs/roadmap/implementation.md` until release preparation
|
||||
retires the completed roadmap material.
|
||||
|
||||
## Baseline validation
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
rendering responsibilities without prescribing an interface or package for each.
|
||||
Dependencies must preserve those boundaries; package splits and abstractions
|
||||
|
||||
@@ -181,6 +181,35 @@ distinguish decision from implementation, and documentation links pass.
|
||||
Run `go test . -run 'LocalDocumentationLinks|DependencyImportBoundary|Example|Facade'`
|
||||
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
|
||||
|
||||
Status: not started.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
that the view "does not retain history"; the constructor contradicts that claim.
|
||||
The historical completion record at
|
||||
`28e7fc5:docs/roadmap/implementation.md` also states that the view "does not
|
||||
retain history"; the constructor contradicts that claim.
|
||||
|
||||
### Correction
|
||||
|
||||
|
||||
@@ -38,11 +38,13 @@ confinement check.
|
||||
|
||||
## Evidence and handoff
|
||||
|
||||
The completed staged record in [implementation.md](implementation.md) maps all
|
||||
twelve acceptance criteria to focused lifecycle, provenance, ownership, consumer,
|
||||
explanation, and 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.
|
||||
The completed staged record is preserved at
|
||||
`28e7fc5:docs/roadmap/implementation.md`; retrieve it with
|
||||
`git show 28e7fc5:docs/roadmap/implementation.md`. It maps all twelve acceptance
|
||||
criteria to focused lifecycle, provenance, ownership, consumer, explanation, and
|
||||
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
|
||||
version, write a version-matched note, retire this completed roadmap with link
|
||||
|
||||
85
facade_compatibility_test.go
Normal file
85
facade_compatibility_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user