|
| 1 | +# ADR-0006: `src/local/config/` sparse overlay replaces `config/override/` |
| 2 | + |
| 3 | +**Date:** 2026-05-17 |
| 4 | +**Status:** Accepted |
| 5 | +**PR:** feature/local-config-resolver-and-override (v0.15.0) |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Context |
| 10 | + |
| 11 | +The original framework config mechanism offered a binary choice: use |
| 12 | +`config/default/` (shipped defaults) **or** replace it entirely with |
| 13 | +`config/override/` (full-tree copy). This had three significant problems: |
| 14 | + |
| 15 | +1. **Whole-tree requirement.** A customer who wanted to change a single key in |
| 16 | + `global.json` had to copy the entire `config/default/` tree into |
| 17 | + `config/override/` and keep it in sync across framework upgrades. A missed |
| 18 | + sync would silently hide new default values. |
| 19 | + |
| 20 | +2. **Coupling across config files.** The original resolver treated the presence |
| 21 | + of any non-hidden file in `config/override/` as a signal to use that tree |
| 22 | + exclusively for _all_ framework config reads. Adding a `logger.json` override |
| 23 | + inadvertently activated the override tree for global config, requiring the |
| 24 | + override tree to be complete. |
| 25 | + |
| 26 | +3. **Override logic scattered inside the loader.** The loader (`load_framework_config`) |
| 27 | + was responsible for detecting whether `config/override/` was active, emitting |
| 28 | + deprecation warnings, and loading the correct file. This mixed concerns and made |
| 29 | + the detection logic duplicated at every call site (especially for multi-file |
| 30 | + resolution like `GLOBAL_CONFIG`). |
| 31 | + |
| 32 | +The `src/local/` fork-safe area introduced in ADR-0001 provides a natural home |
| 33 | +for customer customisations. Config overrides should follow the same pattern. |
| 34 | + |
| 35 | +## Decision |
| 36 | + |
| 37 | +Introduce `src/local/config/` as a **sparse overlay directory** whose files are |
| 38 | +**deep-merged on top of their `src/config/default/` equivalents** at runtime. |
| 39 | + |
| 40 | +### Separation of concerns |
| 41 | + |
| 42 | +Override detection and deprecation warnings are the **caller's responsibility**. |
| 43 | +`load_framework_config` is a pure loader with no override logic: |
| 44 | + |
| 45 | +- **`DLTPipelineBuilder._load_framework_global_config`** checks |
| 46 | + `config/override/` for global config files, emits the `DeprecationWarning` if |
| 47 | + active, stores the resolved root in `self._active_config_path`, and passes it |
| 48 | + to `load_framework_config`. `self._active_config_path` is reused by |
| 49 | + `_setup_operational_metadata` so the override detection only runs once per |
| 50 | + pipeline initialisation. |
| 51 | + |
| 52 | +- **`load_framework_logger_config`** independently checks |
| 53 | + `config/override/logger.json`, emits its own `DeprecationWarning` if active, |
| 54 | + and passes the resolved root to `load_framework_config`. Logger config is |
| 55 | + detected separately from global config because a customer may have one but |
| 56 | + not the other. |
| 57 | + |
| 58 | +### `load_framework_config` API |
| 59 | + |
| 60 | +```python |
| 61 | +load_framework_config( |
| 62 | + name: str | Sequence[str], |
| 63 | + framework_path: str, |
| 64 | + config_path: str = FrameworkPaths.CONFIG_PATH, # caller-resolved active root |
| 65 | + fail_on_not_exists: bool = True, |
| 66 | +) -> Dict |
| 67 | +``` |
| 68 | + |
| 69 | +Behaviour: |
| 70 | + |
| 71 | +1. When `name` is a **sequence** (e.g. `FrameworkPaths.GLOBAL_CONFIG = |
| 72 | + ("global.json", "global.yaml", "global.yml")`): |
| 73 | + - Find all matching files inside `config_path`. |
| 74 | + - Raise `ValueError` if more than one match is found. |
| 75 | + - Raise `FileNotFoundError` (or return `{}` if `fail_on_not_exists=False`) |
| 76 | + if no match is found. |
| 77 | + - Resolve to the single matching filename and proceed. |
| 78 | +2. Load the file from `os.path.join(framework_path, config_path, name)`. |
| 79 | +3. If `src/local/config/<name>` exists, **deep-merge** it on top: |
| 80 | + - Dict values merged recursively; overlay wins on conflicts. |
| 81 | + - Non-dict values and lists replaced wholesale. |
| 82 | + - Keys absent from the overlay retain their default values. |
| 83 | +4. Return the merged dict. |
| 84 | + |
| 85 | +No override detection. No warnings. Callers pass the already-resolved `config_path`. |
| 86 | + |
| 87 | +### `resolve_framework_config_dir` (directory-based config) |
| 88 | + |
| 89 | +For directory-based config (e.g. `dataflow_spec_mapping/`): |
| 90 | + |
| 91 | +1. If `src/local/config/<subdir>/` exists, return it (local wins entirely). |
| 92 | +2. Otherwise return `src/config/default/<subdir>/`. |
| 93 | + |
| 94 | +### `resolve_framework_config_path` (legacy shim) |
| 95 | + |
| 96 | +Kept until v1.0.0 for any external callers. Emits a `DeprecationWarning` when |
| 97 | +`config/override/` is active. Framework internals no longer call this. |
| 98 | + |
| 99 | +### Deprecation timeline |
| 100 | + |
| 101 | +| Version | Action | |
| 102 | +|---------|--------| |
| 103 | +| v0.13.0 | `config/override/` deprecated; `DeprecationWarning` added | |
| 104 | +| v0.15.0 | `src/local/config/` introduced; all framework call sites migrated to `load_framework_config`; override detection moved to callers | |
| 105 | +| v1.0.0 | `config/override/` support and `resolve_framework_config_path` removed | |
| 106 | + |
| 107 | +## Consequences |
| 108 | + |
| 109 | +- A customer who wants to change one key in `global.json` creates a one-field |
| 110 | + `src/local/config/global.json` — no need to maintain a full copy. |
| 111 | +- Framework upgrades that add new default keys automatically appear in the |
| 112 | + merged config; only explicitly overridden keys are affected. |
| 113 | +- `config/override/` still works during the deprecation window; a |
| 114 | + `DeprecationWarning` is emitted by the caller (not deep inside the loader), |
| 115 | + giving an accurate stack frame in the pipeline logs. |
| 116 | +- The multi-file resolution check (preventing duplicate `global.json` and |
| 117 | + `global.yaml` coexisting) now lives inside `load_framework_config` when a |
| 118 | + sequence is passed — consistent with the pipeline bundle's equivalent check in |
| 119 | + `_load_pipeline_bundle_global_config_file`. |
| 120 | +- The `logger.json` override (pluggable logger, ADR-0003) now lives in |
| 121 | + `src/local/config/logger.json` rather than `config/override/`. |
| 122 | +- Substitutions and secrets files (which include workspace-environment prefixes) |
| 123 | + are not yet covered by `load_framework_config` and remain on the legacy path |
| 124 | + until a follow-up PR addresses the workspace-env prefix complexity. |
0 commit comments