Clarify import roadmap decisions using Ansible conventions

This commit is contained in:
2026-10-18 16:28:38 +00:00
parent 137769f3f1
commit 82b289f4fe

522
docs/roadmap/imports.md Normal file
View File

@@ -0,0 +1,522 @@
# Import Ergonomics And Permissive Defaults
Status: working draft / brainstorming notes. This document captures a proposed
direction for discussion, not an implemented contract or an implementation plan.
The selected direction separates directory searches, filename matching,
recursion, and absence handling. Optional-by-default imports are the intended
direction. The Ansible-aligned decisions below are settled for this roadmap;
remaining choices have recommendations and alternatives under Open Decisions.
All new syntax below is illustrative and unimplemented.
## Guiding Philosophy
ConfigKit should provide predictable configuration mechanisms and avoid imposing
application policy. Its defaults should permit optional contributions to be
absent, with explicit options for increased strictness when needed. That choice
may come from an operator or the consuming application; authored import
declarations can also express requirements on contributions.
Permissiveness should distinguish absence from invalid supplied configuration:
> Optional contributions may be absent by default. Consumers can explicitly
> require them. Supplied configuration must still be structurally valid and
> interpretable under the selected composition rules.
This is a general design direction worth making more explicit and consistent
across ConfigKit. It is not blanket authorization to weaken existing contracts,
ignore errors, or add a global permissiveness switch. Other defaults should be
evaluated individually against this principle.
## Current Baseline
The [current import contract](../config.md#explicit-imports) uses `path` or
`glob`, with required imports by default and `optional: true` to permit absence.
Glob matching uses slash-based Go `path.Match` syntax. Recursive `**` syntax is
not supported; authors can match particular depths with patterns such as
`prompts/*.yaml` and `prompts/*/*.yaml`.
The proposed changes concern naming, discovery, and absence handling. Import
destinations, composition, precedence, and resource identity remain separate
concerns.
## Proposed Import Vocabulary
Use a direct-file form and a directory-search form. The latter separates the
search root from its filename filter rather than inferring traversal from a
combined path pattern:
```yaml
configkit:
imports:
- dir: prompts
pattern: '*.yaml'
recursive: true
destination: [prompts]
placement: sequence-item
- path: defaults.yaml
required: true
```
The first declaration searches `prompts` and its descendants for matching
filenames, placing each source mapping as one prompt item, and permits no
matches. The second explicitly requires its target file. Sequence composition
still requires the consumer's existing registration, as explained below.
The fields have distinct jobs:
| Field | Proposed meaning |
| --- | --- |
| `path` | Import one named file, relative to the declaring file |
| `dir` | Search a directory, relative to the declaring file |
| `pattern` | Filter candidate files by filename within the searched directories |
| `recursive` | Include descendant directories when true |
| `depth` | Bound directory-search depth; 1 means direct files, 0 or omission means unlimited when recursive |
| `required` | Fail when the declaration contributes no matching file |
| `destination`, `placement` | Apply the existing placement rules to each selected source |
This follows the familiar separation of traversal and filtering in Ansible's
[find](https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/find_module.html)
and [include_vars](https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/include_vars_module.html)
interfaces, without adopting all their spellings, defaults, or matching modes.
## Decisions Resolved From Ansible
The comparison covers `ansible.builtin.find`, `ansible.builtin.include_vars`,
`ansible.builtin.fileglob`, and `ansible.windows.win_find`. A consistent approach
means agreement among the relevant interfaces that offer the capability, not
that every Ansible module exposes it. Existing ConfigKit contracts and the
already selected optional-by-default direction remain authoritative; this is
not a general Ansible compatibility mode.
The following choices are settled:
- **Separate traversal from filename filtering.** Directory roots and filename
filters have separate roles; filters operate on basenames in visited
directories. A directory failing the filename filter does not prune its
descendants. This agrees with the directory-search interfaces above.
See [find's parameters](https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/find_module.html#parameters)
and the [include_vars implementation](https://github.com/ansible/ansible/blob/stable-2.20/lib/ansible/plugins/action/include_vars.py).
- **Include an optional integer `depth` control.** All three directory-search
interfaces use that spelling. Direct files are at depth 1, and each additional
directory adds one. Omission permits unlimited depth when recursion is
enabled. See [include_vars](https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/include_vars_module.html#parameters)
and [win_find](https://docs.ansible.com/projects/ansible/latest/collections/ansible/windows/win_find_module.html#parameters).
- **Treat `depth: 0` as unlimited.** This follows `include_vars` and agrees with
the current [find implementation](https://github.com/ansible/ansible/blob/stable-2.20/lib/ansible/modules/find.py)
and [win_find implementation](https://github.com/ansible-collections/ansible.windows/blob/main/plugins/modules/win_find.ps1).
The latter two document unlimited omission more clearly than explicit zero;
this choice uses source-confirmed behavior, not a claimed universal documented
Ansible guarantee. Negative values remain a separate open decision.
- **Disabling recursion takes precedence over depth.** With `recursive: false`,
search only direct files, even if a valid larger or unlimited depth is supplied.
Depth does not enable recursion. Both
[find](https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/find_module.html#parameters)
and [win_find](https://docs.ansible.com/projects/ansible/latest/collections/ansible/windows/win_find_module.html#parameters)
explicitly use this rule where a separate recursion switch exists.
- **Directory searches select files, not directory documents.** Directories are
traversal containers, even when their names match `pattern`. This agrees with
the default file selection in the find modules and the file-oriented
[fileglob lookup](https://docs.ansible.com/projects/ansible/latest/collections/ansible/builtin/fileglob_lookup.html).
Existing regular-file and symlink rules still govern opening selected sources;
the legacy `glob` contract is not implicitly changed by this new search form.
The accepted `recursive` spelling is retained. The formerly tentative
`max_depth` spelling becomes `depth`. Resource budgets remain separate from
depth, and the current limits still apply to an unlimited-depth search.
## Discovery Semantics
### Filename Matching
`pattern` applies to a file's basename, not its full relative path. For example,
`*.yaml` matches both `prompts/one.yaml` and
`prompts/weather/daily/summary.yaml` when the latter's directory is visited.
Recursion changes which directories are visited; it does not change what `*`
means. A nonrecursive search considers only files directly inside `dir`.
Recommend retaining the familiar `path.Match` wildcard grammar for filename
filters. The name `pattern` does not by itself imply regular expressions or
automatic dialect detection. Exact filter defaults and the treatment of
existing path-glob use cases remain open below.
### Recursive Discovery And Depth Limits
Use `recursive: true` for recursive discovery, optionally bounded by `depth`.
The motivation is to let authors organize definitions in subdirectories without
updating import declarations for every depth. Directory organization should not
change resource identity or the effective configuration destination.
The selected model uses an explicit search root and recursion flag, rather than
introducing recursive `**` syntax or inferring a root from a wildcard prefix.
The default when `recursive` is omitted remains open; examples specify it.
The settled depth convention is:
- Files directly inside the search root are at depth `1`.
- Each additional directory increases depth by one.
- Omitting `depth`, or setting it to 0, imposes no caller-selected depth limit
when recursion is enabled; existing loader resource limits still apply.
- `recursive: false` selects only direct files regardless of a valid depth value.
- Depth controls discovery only, not namespaces, identities, or destinations.
Remaining validation and traversal choices are listed below. Recursive discovery
must fit the existing filesystem containment, repeated-source, and load-budget
contracts rather than silently changing them. In particular, examined entries,
including nonmatches, still consume the visit budget; a small number of matches
does not authorize an unbounded filesystem scan. This is an existing ConfigKit
constraint, not a new Ansible-derived decision.
## Illustrative Examples
These examples describe the proposed interface, not configuration accepted by
the current release. They assume the consumer has enabled imports. File paths
and directory roots resolve from the declaring file; destinations use the
existing relative-placement rules.
### Import One File
```yaml
configkit:
imports:
- path: defaults.yaml
```
Import `defaults.yaml` into the declaring source's mapping scope. Under the
proposed default, a missing file contributes nothing. Add `required: true` when
its presence is necessary. A present but invalid file still fails loading.
### Recursively Discover Mapping Fragments
```yaml
configkit:
imports:
- dir: services
pattern: '*.yaml'
recursive: true
destination: [services]
```
This discovers files such as `services/api.yaml` and
`services/regional/worker.yaml`, but not `services/README.md`. Each file contains
named service entries, for example `api: {port: 8080}` or
`worker: {concurrency: 4}`. They compose beneath `services` under the existing
strict composition rules. Directory names do not create additional mapping
keys. Missing directories or no matching files contribute nothing.
Adding `depth: 2` limits discovery to direct files and files one directory below
`services`: it still selects `services/regional/worker.yaml`, but not
`services/regional/east/worker.yaml`. With `recursive: false`, only direct files
are selected, even if `depth: 2` remains present.
### Require A Recursive Prompt Collection
```yaml
configkit:
imports:
- dir: prompts
pattern: '*.prompt.yaml'
recursive: true
required: true
destination: [prompts]
placement: sequence-item
```
This selects `prompts/welcome.prompt.yaml` and
`prompts/weather/daily/summary.prompt.yaml`, but not
`prompts/weather/helpers.yaml`. Each selected file contains one prompt mapping,
such as `id: prompt.welcome` and `text: Welcome`. The consumer registers the
`prompts` sequence with identity field `id` through the existing
`Definition.SequenceCompositions` API. Distinct IDs compose into one sequence;
duplicate IDs fail strict composition. Overlays continue replacing sequences.
Here `required: true` means at least one file must match across the entire
search, not that every visited directory must contain a matching file. The
filename convention keeps helper files out of this discovery declaration; it
does not prevent a selected file from explicitly importing a helper. Existing
repeated-source rules still apply if declarations select overlapping sources.
## Optional Imports By Default
The preferred direction is optional-by-default for both explicit files and
directory searches, with `required: true` opting into failure on absence. This
deliberately supersedes the earlier suggestion to retain required-by-default
imports.
The table assumes the library's default absence policy. A consumer-selected
default or enforcement option remains open below.
| Situation | Proposed behavior |
| --- | --- |
| Missing path; `required` omitted or false | Contribute nothing |
| Missing search directory or no matching files; `required` omitted or false | Contribute nothing |
| Missing file, missing search directory, or zero matches with `required: true` | Fail with an import diagnostic |
| Invalid pattern or malformed import declaration | Fail |
| Permission failure, unreadable matched file, or malformed YAML | Fail |
| Composition conflict or other invalid supplied configuration | Fail under the applicable existing rules |
An optional declaration is permission for absence, not permission to ignore a
present contribution that cannot be read or interpreted. Optionality must not
turn processing failures into apparently successful partial configuration.
This default supports installations with different optional components without
requiring ceremony for every potentially absent directory or file. Its tradeoff
is that a misspelled path or pattern can also contribute nothing. Authors and
applications that need an import to exist can state that requirement explicitly;
applications remain responsible for validating their necessary final values.
Final validation does not automatically detect every unintentionally omitted
contribution.
## Who Chooses Strictness?
The general philosophy permits operators and consuming applications to choose
stricter behavior. Import authors can express local requirements through
`required: true`. This does not yet specify an application-wide override API.
Keep the distinction between choosing defaults and overriding explicit intent.
If an operator or application should be able to strengthen or relax a requirement
declared in an imported document, define that capability and its precedence
separately. Optional-by-default alone does not imply that an authored requirement
can be overridden, or that requirements propagate to transitive imports.
## Migration
Changing absence defaults changes behavior even when existing YAML contains no
optionality field. Renaming keys also changes the accepted syntax. Pre-1.0 is a
useful time to reconsider these contracts, but adoption still needs explicit
migration guidance.
To preserve current intent, currently required imports would acquire
`required: true`; existing `optional: true` declarations could omit the new field
or use `required: false`. A simple `glob: prompts/*.yaml` becomes `dir: prompts`
plus `pattern: '*.yaml'` and `recursive: false`, preserving its discovery scope.
This is not a mechanical rename of `glob` to `pattern`: current globs can also
select particular directory names or depths. Their compatibility path must be
settled before removing any existing form.
## Open Decisions
The recommendations below are not settled decisions. Each concerns either
inconsistent Ansible behavior or a ConfigKit-specific policy without a direct
Ansible counterpart. They do not reopen the accepted absence policy, placement,
strict composition, or source-version rules.
### 1. Recursion Default
**Ansible evidence:** `find` and `win_find` default to nonrecursive searches;
`include_vars` recursively loads directories by default. There is no common
default across these interfaces. See their parameter references above.
**Recommendation:** Default `recursive` to false. This keeps discovery local
unless the declaration explicitly requests descendants and makes migration from
simple current globs straightforward. The cost is one extra field for recursive
collections.
**Alternative:** Default to true, following `include_vars`. Collection authors
could reorganize files into subdirectories without revising declarations, but
adding a subdirectory could also add contributions to an existing import.
### 2. Matching Language And Filter Shape
**Ansible evidence:** `find` supports shell patterns and optional regex;
`include_vars.files_matching` is a regex; `win_find` uses PowerShell wildcards or
regex. Their case and matching details differ. There is no single exact grammar.
**Recommendation:** Keep one scalar `pattern` using ConfigKit's existing
case-sensitive Go `path.Match` grammar, applied to basenames. This reuses a
familiar contract, including escapes and character classes, without introducing
regex mode or platform-dependent case rules. It cannot express every regex or
an arbitrary union of suffixes in one declaration; separate declarations remain
available, subject to existing repeated-source rules.
**Alternative:** Add an explicit regex mode and/or a list of patterns whose
matches are unioned within one declaration. This better supports filters such
as both `.yaml` and `.yml` without overlapping imports, but adds syntax, matching
rules, and validation. Regex would need a specified Go grammar rather than a
promise of Python or PowerShell compatibility. Exclusion filters could accompany
that design, but are not assumed in the initial recommendation.
### 3. Omitted Filename Filter
**Ansible evidence:** The find modules permit unrestricted filename selection.
`include_vars` also has an extension policy, defaulting to `json`, `yaml`, and
`yml`, with separate handling of unknown extensions. Its loading behavior is
not equivalent to a general-purpose find operation.
**Recommendation:** Omitted `pattern` means all filenames, with no implicit
extension filter. This keeps selection independent of filename conventions and
supports extensionless configuration. A selected README or other invalid file
still causes a load error; authors of mixed directories should supply a filter.
**Alternative:** Default discovery to `.yaml` and `.yml`, with an explicit way
to select all files or other extensions. This suits typical configuration
directories and avoids incidental README selection, but adds an extension
policy and an override mechanism that explicit `path` imports do not need.
### 4. Invalid Depth Values
**Ansible evidence:** Positive depth, unlimited omission, and disabled-recursion
precedence have useful common semantics. The implementations do not provide a
consistent negative-depth contract; accepting a value is not evidence of a
useful shared meaning. See the implementation links above.
**Recommendation:** Accept nonnegative YAML integers only. Zero means unlimited;
negative, null, string, boolean, or fractional values are malformed declarations,
even when recursion is disabled. This follows ConfigKit's typed directive style
and gives likely mistakes a direct diagnostic without imposing an arbitrary
maximum depth.
**Alternative:** Treat negative integers as unlimited too, and null as omission.
That can ease generated configurations but adds redundant spellings and risks
turning an erroneous bound into a wider search. Neither interpretation is
required by the settled rule that disabled recursion ignores a valid bound.
### 5. Hidden Files And Directories
**Ansible evidence:** The find modules expose hidden-file filtering, while
`include_vars` has no equivalent hidden-entry exclusion in its directory walk.
Also, filtering a hidden entry is not necessarily the same as pruning a hidden
directory's descendants. See
[find](https://github.com/ansible/ansible/blob/stable-2.20/lib/ansible/modules/find.py)
and [include_vars](https://github.com/ansible/ansible/blob/stable-2.20/lib/ansible/plugins/action/include_vars.py).
**Recommendation:** Give dot-prefixed names no special treatment: visit their
directories within the selected depth and select files by `pattern`. This fits
the existing matcher and avoids an implicit exclusion. Broad recursive searches
may include configuration in hidden directories, so the search root and filter
should reflect the intended collection.
**Alternative:** Add `hidden`, defaulting to false, with an explicit definition
covering both hidden-file selection and hidden-directory pruning. This can
exclude incidental metadata directories, but adds policy and a platform choice
between dot prefixes and filesystem hidden attributes. Callers would need to
opt in when their collections intentionally use hidden names.
### 6. Directory Symlinks And Traversal Cycles
**Ansible evidence:** The find modules default to not following directory links;
the inspected `include_vars` implementation walks with link following enabled.
There is no common default to adopt.
**Recommendation:** Follow contained directory symlinks using ConfigKit's
existing canonicalization and containment rules, preserving the current ability
to select through contained symlinks. For the new recursive walk, detect a
canonical directory repeated on the active traversal path and report a cycle.
Do not silently deduplicate imported files reached through aliases; existing
source-repeat errors remain applicable. This supports linked collections but
requires directory-cycle handling in addition to import-cycle handling.
**Alternative:** Add a traversal `follow` option defaulting to false, skipping
directory symlinks encountered below the search root unless enabled. Keep
explicitly named roots and file targets under existing source-opening rules.
This reduces accidental traversal through linked trees, but introduces a new
distinction between discovery and explicit access and requires callers to opt
in for linked collections. Both choices retain native-filesystem semantics;
neither promises symlink capabilities absent from the supplied filesystem.
### 7. Match Ordering
**Ansible evidence:** `include_vars` sorts directories and filenames;
`win_find` documents results sorted by path. POSIX `find` describes top-down
searching and does not promise the same global ordering. These are not identical
ordering contracts.
**Recommendation:** Sort all selected slash-relative paths lexicographically
within each declaration, then use existing depth-first import expansion. This
extends ConfigKit's existing glob order and stabilizes sequence assembly and
diagnostics. It requires collecting bounded candidates before expansion.
**Alternative:** Load direct files first, then visit sorted subdirectories
recursively. This offers a natural directory-oriented traversal and can begin
loading sooner, but differs from existing global match ordering and can change
sequence item positions. Neither choice grants replacement precedence within
strict composition.
### 8. Application And Operator Strictness Controls
**Ansible evidence:** Task failure controls and module-specific options are not
a direct counterpart to defaults for ConfigKit's transitive import graph.
Ansible cannot settle this public API choice.
**Recommendation:** Add a load-level default for whether imports are required,
used only when a declaration omits `required`. The library default stays false;
an application can choose true and expose that choice to operators through its
own interface. Apply the default independently to each declaration, including
transitive imports. Explicit true or false remains authoritative. This enables
stricter deployments while preserving intentionally optional imports; it does
not enforce that every authored optional declaration succeeds.
**Alternative:** Offer an enforcement mode requiring every declaration to
match, including those explicitly marked `required: false`. That provides a
stronger audit policy but overrides authors' intent and needs clear precedence
and diagnostics. A smaller alternative is authored requirements only for now;
it avoids a public option but leaves applications unable to choose a common
default without editing source declarations.
### 9. Evidence For Absent Optional Imports
**Ansible evidence:** `fileglob` returns an empty result for no matches;
`include_vars` reports included filenames; the find modules expose richer search
results. There is no common inspection model for an absent import declaration.
**Recommendation:** Record minimal structured inspection evidence for an
optional declaration that selects nothing: its declaring source/location,
selector, and absent outcome. Emit no warning by default and do not fabricate a
loaded-source record. This helps distinguish intentional absence from a typo,
but requires an inspection API decision and associated ownership tests. Design
the exact record shape during implementation planning, using existing evidence
structures where appropriate.
**Alternative:** Add no new inspection surface initially; retain loaded-source
evidence and let `required: true` turn absence into a diagnostic when needed.
This keeps the feature smaller, but successful optional omissions are harder to
investigate. Existing inspection contracts must remain intact either way.
### 10. Selector Combinations And Directory-Only Options
**Ansible evidence:** `include_vars` separates file-only and directory-only
arguments, while the find modules are directory-search interfaces. This does
not settle how ConfigKit should combine its new form with retained path globs.
**Recommendation:** Exactly one selector per declaration: `path`, `dir`, or
`glob` if retained. Use a literal directory root; `pattern`, `recursive`, and
`depth` apply only to `dir`. Diagnose irrelevant fields rather than silently
ignoring them. This makes declarations unambiguous and keeps the roles of file
selection and traversal visible, at the cost of templates needing to omit
inapplicable fields. This is a proposed validation rule, not an already settled
restriction.
**Alternative:** Permit directory-search controls on a `path` declaration and
ignore them, retaining exclusive selectors. Shared templates become easier,
but a declaration can appear recursive without being so. Wildcard directory
roots are another viable extension: useful for selecting collections, but they
need root-expansion, depth-origin, ordering, and overlap rules. Retaining `glob`
is the simpler way to preserve existing wildcard-directory use cases.
### 11. Compatibility And Migration
**Ansible evidence:** There is no Ansible precedent that determines ConfigKit's
compatibility commitments or migration schedule.
**Recommendation:** Retain `glob` alongside `dir` because it can select directory
segments and exact depths that basename filters cannot express. Introduce
`required` while accepting explicit `optional` as its inverse for one documented
transition release. Reject declarations containing both fields to avoid two
authorities for the same choice. This retains existing expressiveness and eases
adoption, at the cost of temporary parser and documentation complexity.
The intended optional default applies when both fields are absent, including
legacy `path` and `glob` declarations; old spelling alone does not preserve the
old default. The application default, if adopted, also applies to this absent
case. Explicit legacy `optional: false` maps to `required: true`, and explicit
`optional: true` maps to `required: false`. Release notes must call out the
behavior change and show how to preserve existing requirements. Under the
[release policy](../policy/release.md#versioning-and-compatibility), this belongs
in a pre-1.0 minor release, not a patch release.
**Alternative:** Remove `optional` immediately in that minor release while
retaining `glob`. This establishes one vocabulary sooner, but every declaration
using the old field needs migration before it can load. If a transition is
chosen, accepting both fields when they agree is also viable; it helps generated
documents at the cost of extra normalization rules. Removing `glob` too would
need an expressiveness decision and migration for wildcard directory segments;
it cannot be treated as a simple field rename.