diff --git a/docs/adr/0006-public-facade.md b/docs/adr/0006-public-facade.md new file mode 100644 index 0000000..061ca34 --- /dev/null +++ b/docs/adr/0006-public-facade.md @@ -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. diff --git a/docs/development.md b/docs/development.md index 376044d..678c297 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index 0fb9f59..c881b03 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -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 diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 56e8e14..3fe1a0f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index ac64fda..6b568a3 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -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 diff --git a/docs/roadmap/source-origins-during-resolution.md b/docs/roadmap/source-origins-during-resolution.md index 7790016..ecbe684 100644 --- a/docs/roadmap/source-origins-during-resolution.md +++ b/docs/roadmap/source-origins-during-resolution.md @@ -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 diff --git a/facade_compatibility_test.go b/facade_compatibility_test.go new file mode 100644 index 0000000..b199531 --- /dev/null +++ b/facade_compatibility_test.go @@ -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) + } +}