feat(#910): federated config merge in config-loader (ADR-857 phase 3b) (#914)

Build the federated config merge: each capability owns its config-key slice
(ADR-857 decision 3 / ADR-894), and loadConfig merges them defensively. The
registry now emits a full configSchema index ({key:{owner,type,default,
description}}, generator-validated); a new src/federated-config.cts resolves
federated keys defensively (skip central keys -> pending-migration warning,
skip malformed slices -> warning never throw, else type-checked user override
?? default, with nested dotted-path lookup and enum validation); and loadConfig
applies the overlay on every return path.

Wired as a provably-empty no-op channel: every UI-pilot key is still central,
so validKeys is empty and loadConfig returns byte-identical output on all paths
(identity return when the overlay is empty; shared CONFIG_DEFAULTS never
mutated). Registry-only; no key is cut over; nothing in the live loop changes.

Closes #910

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-08 23:37:41 -04:00
committed by GitHub
parent 5bf77a527f
commit 6dbd895028
13 changed files with 1981 additions and 9 deletions

View File

@@ -35,8 +35,30 @@ const { detectSubRepos } = coreUtilsModule;
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys } from './configuration.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configSchema = require('./config-schema.cjs');
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS } = configSchema;
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey: _isValidConfigKeyFn } = configSchema;
import { KNOWN_RUNTIMES, KNOWN_PROVIDERS } from './model-catalog.cjs';
// ─── Federated Config (ADR-857 phase 3b) ─────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-require-imports
import federatedConfigModule = require('./federated-config.cjs');
const { mergeFederatedConfig } = federatedConfigModule;
// The capability-registry.cjs is generated and lives in the same gsd-core/bin/lib/ output dir.
// Both config-loader.cjs and capability-registry.cjs land in gsd-core/bin/lib/ at build time.
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const _capabilityRegistryReal: { configSchema?: Record<string, unknown> } = require('./capability-registry.cjs');
// Module-level registry reference. Defaults to the real generated registry.
// Overridable for tests via _setFederatedRegistryForTests.
let _capabilityRegistry: { configSchema?: Record<string, unknown> } = _capabilityRegistryReal;
/** Test-only seam: inject a synthetic registry. Call _resetFederatedRegistryForTests() to restore. */
function _setFederatedRegistryForTests(reg: { configSchema?: Record<string, unknown> }): void {
_capabilityRegistry = reg;
}
/** Test-only seam: restore the real generated registry. */
function _resetFederatedRegistryForTests(): void {
_capabilityRegistry = _capabilityRegistryReal;
}
// ─── File & Config utilities ──────────────────────────────────────────────────
@@ -272,6 +294,81 @@ function _resetRuntimeWarningCacheForTests(): void {
_warnedConfigKeys.clear();
}
// ─── FIX 2: Federated overlay helpers ────────────────────────────────────────
/**
* Apply federated key values into a mutable config object.
* Handles N-level dotted keys (e.g. "a.b.c" → obj.a.b.c).
* Only adds keys that are not already present (does not clobber).
* Inline prototype-pollution guards at every segment.
*/
function _applyFederatedValues(
obj: Record<string, unknown>,
values: Record<string, unknown>,
validKeys: string[],
): void {
for (const dottedKey of validKeys) {
// S2: inline literal guard on full key
if (dottedKey === '__proto__' || dottedKey === 'constructor' || dottedKey === 'prototype') continue;
const parts = dottedKey.split('.');
if (parts.length === 1) {
const topKey = parts[0];
if (topKey !== '__proto__' && topKey !== 'constructor' && topKey !== 'prototype') {
if (!Object.prototype.hasOwnProperty.call(obj, topKey)) {
obj[topKey] = values[dottedKey];
}
}
} else {
// N-level nested key: traverse/create intermediate objects
let cur: Record<string, unknown> = obj;
let ok = true;
for (let i = 0; i < parts.length - 1; i++) {
const seg = parts[i];
// S2: inline literal guard on each segment
if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') { ok = false; break; }
if (!Object.prototype.hasOwnProperty.call(cur, seg) || cur[seg] === null) {
cur[seg] = {};
}
if (typeof cur[seg] !== 'object' || Array.isArray(cur[seg])) { ok = false; break; }
cur = cur[seg] as Record<string, unknown>;
}
if (!ok) continue;
const leafKey = parts[parts.length - 1];
// S2: inline literal guard on leaf
if (leafKey === '__proto__' || leafKey === 'constructor' || leafKey === 'prototype') continue;
if (!Object.prototype.hasOwnProperty.call(cur, leafKey)) {
cur[leafKey] = values[dottedKey];
}
}
}
}
/**
* FIX 2: Apply the federated overlay to a base config object.
* When validKeys is empty (current registry — all keys are central),
* returns the baseConfig UNCHANGED (true no-op, preserves reference identity).
* When validKeys is non-empty, applies values into a shallow clone to avoid
* mutating shared CONFIG_DEFAULTS/module constants.
*/
function _applyFederatedOverlay(
baseConfig: Record<string, unknown>,
userConfig: Record<string, unknown>,
): Record<string, unknown> {
const _fedRegistrySchema = _capabilityRegistry.configSchema;
if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object') return baseConfig;
const _fedOverlay = mergeFederatedConfig({
configSchema: _fedRegistrySchema,
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
userConfig,
});
// True no-op: if no federated keys, return UNCHANGED (byte-identical, no clone)
if (_fedOverlay.validKeys.length === 0) return baseConfig;
// Clone shallowly to avoid mutating shared constants, then apply nested values
const cloned: Record<string, unknown> = { ...baseConfig };
_applyFederatedValues(cloned, _fedOverlay.values, _fedOverlay.validKeys);
return cloned;
}
function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<string, unknown> {
const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
? options['workstream']
@@ -397,6 +494,31 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
// Deprecated keys (still accepted for migration, not in config-set)
'depth', 'multiRepo', 'branching_strategy',
]);
// FIX 3: Compute federated overlay BEFORE the unknown-key warning, so that
// federated top-level keys are added to KNOWN_TOP_LEVEL before the check runs.
// This is hoisted out of the try-catch below so validKeys are available here.
let _preWarningFedValidKeys: string[] = [];
try {
const _fedRegistrySchemaEarly = _capabilityRegistry.configSchema;
if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
const _earlyOverlay = mergeFederatedConfig({
configSchema: _fedRegistrySchemaEarly,
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
userConfig: parsed,
});
_preWarningFedValidKeys = _earlyOverlay.validKeys;
for (const dottedKey of _preWarningFedValidKeys) {
const topKey = dottedKey.split('.')[0];
if (topKey !== '__proto__' && topKey !== 'constructor' && topKey !== 'prototype') {
KNOWN_TOP_LEVEL.add(topKey);
}
}
}
} catch {
// Defensive: if registry access fails here, proceed without pre-warning keys
}
const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
if (unknownKeys.length > 0) {
const warnKey = unknownKeys.join(',');
@@ -429,7 +551,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
return defaults.parallelization;
})();
return {
const _baseConfig: Record<string, unknown> = {
model_profile: get('model_profile') ?? defaults.model_profile,
commit_docs: (() => {
const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
@@ -492,14 +614,54 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
claude_md_path: get('claude_md_path') || null,
claude_md_assembly: (parsed['claude_md_assembly']) || null,
};
// ─── ADR-857 phase 3b: federated config overlay ───────────────────────────
// FIX 2: Use the pre-computed _preWarningFedValidKeys (from the FIX 3 block above)
// plus a fresh overlay call to get values. The KNOWN_TOP_LEVEL was already updated.
// TODAY: every UI key is still in the central config-schema, so isCentralKey()
// returns true for all of them → validKeys is empty → _baseConfig is returned UNCHANGED
// (true no-op: no clone, no reorder, byte-identical output).
// This becomes a live channel once a key is atomically removed from the central schema.
try {
if (_preWarningFedValidKeys.length > 0) {
// There are actual federated values — re-use the already-computed overlay
// (we run mergeFederatedConfig again here to get the values map; the validKeys
// are guaranteed identical since it's the same inputs).
const _fedRegistrySchema = _capabilityRegistry.configSchema;
if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
const _fedOverlay = mergeFederatedConfig({
configSchema: _fedRegistrySchema,
isCentralKey: (key: string) => _isValidConfigKeyFn(key),
userConfig: parsed,
});
// Apply dotted-path values (e.g. "workflow.ui_phase" → _baseConfig.workflow.ui_phase)
// WITHOUT clobbering existing keys. N-level nesting supported.
_applyFederatedValues(_baseConfig, _fedOverlay.values, _fedOverlay.validKeys);
}
}
// Pending-migration warnings are suppressed at load time to avoid noisy output on
// every loadConfig call. They are surfaced at registry-generation time (--check/--write).
} catch {
// Defensive: if the federated overlay throws for any reason, return the base config unchanged.
// This keeps loadConfig's no-throw contract intact regardless of capability registry state.
}
return _baseConfig;
} catch {
// Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
if (fs.existsSync(planningDir(cwd, ws))) {
if (rootParsed) {
// Workstream has no config.json: re-parse using root config as the sole source.
// (FIX 2: overlay is applied recursively in the re-entrant loadConfig call)
return loadConfig(cwd, { workstream: null });
}
return defaults;
// FIX 2: Apply the federated overlay on the no-config path.
// With the current registry (all keys central), _applyFederatedOverlay returns
// `defaults` UNCHANGED (true no-op, preserves byte-identical output).
try {
return _applyFederatedOverlay(defaults, {});
} catch {
return defaults;
}
}
try {
const home = process.env['GSD_HOME'] || os.homedir();
@@ -507,7 +669,7 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
const raw = platformReadSync(globalDefaultsPath);
if (raw === null) throw new Error('missing');
const globalDefaults = JSON.parse(raw) as Record<string, unknown>;
return {
const _globalBaseCfg: Record<string, unknown> = {
...defaults,
model_profile: (globalDefaults['model_profile']) ?? defaults.model_profile,
commit_docs: (globalDefaults['commit_docs']) ?? defaults.commit_docs,
@@ -534,8 +696,21 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
agent_skills: (globalDefaults['agent_skills']) || {},
response_language: (globalDefaults['response_language']) || null,
};
// FIX 2: Apply federated overlay on global-defaults path.
// With the current registry this is a true no-op (returns _globalBaseCfg unchanged).
try {
return _applyFederatedOverlay(_globalBaseCfg, globalDefaults);
} catch {
return _globalBaseCfg;
}
} catch {
return defaults;
// FIX 2: Apply federated overlay on the final fallback path.
// With the current registry this is a true no-op (returns `defaults` unchanged).
try {
return _applyFederatedOverlay(defaults, {});
} catch {
return defaults;
}
}
}
}
@@ -553,4 +728,6 @@ export = {
_warnedConfigKeys,
_gitIgnoredCache,
RUNTIME_OVERRIDE_TIERS,
_setFederatedRegistryForTests,
_resetFederatedRegistryForTests,
};

230
src/federated-config.cts Normal file
View File

@@ -0,0 +1,230 @@
/**
* Federated Config — Defensive merge of capability-declared config keys
*
* ADR-857 phase 3b: wires the Capability Registry's configSchema into
* loadConfig as a provably-empty no-op channel until capability keys are
* migrated out of the central config-schema.
*
* Exported function:
* mergeFederatedConfig({ configSchema, isCentralKey, userConfig })
* → { values, validKeys, warnings }
*
* Design:
* - For each key in configSchema:
* If isCentralKey(key) → SKIP; push a pending-migration warning.
* Else if slice is malformed → SKIP; push a warning. Never throw.
* Else (valid federated key absent from central):
* resolvedValue = nested userConfig lookup if present & type-matches; else slice.default.
* Add key→resolvedValue to values; add key to validKeys.
* - Guard all object writes with inline literal __proto__/constructor/prototype checks.
* - Zero external dependencies; no ajv; hand-rolled type checks only.
*
* ADR-857 no-op guarantee:
* With the current registry, every UI key is still present in the central
* config-schema, so isCentralKey() returns true for all of them and values
* is always empty. The channel is live but carries no traffic until a key
* is atomically removed from the central schema (the cutover step).
*
* Dependencies: none (zero-dep module).
*/
// ─── Types ─────────────────────────────────────────────────────────────────────
/** Shape of one entry in the capability registry's configSchema index. */
interface ConfigSliceEntry {
owner: string;
type: string;
default: unknown;
description: string;
values?: string[]; // required for enum type
[key: string]: unknown;
}
interface MergeFederatedConfigInput {
/** configSchema index from the capability registry: { [key]: ConfigSliceEntry } */
configSchema: Record<string, unknown>;
/** Returns true if the given key is owned by the central config-schema. */
isCentralKey: (key: string) => boolean;
/** The raw/merged user config object already loaded in loadConfig. */
userConfig: Record<string, unknown>;
}
interface MergeFederatedConfigResult {
/** Resolved values for federated (non-central) keys: { key → resolvedValue } */
values: Record<string, unknown>;
/** Array of keys that are now valid federated keys (i.e. were added to values). */
validKeys: string[];
/** Human-readable diagnostic strings (pending-migration, malformed-slice, type-mismatch). */
warnings: string[];
}
// ─── Allowed slice types (mirrors gen-capability-registry.cjs VALID_CONFIG_SLICE_TYPES) ──
const VALID_SLICE_TYPES = new Set<string>(['boolean', 'string', 'number', 'enum']);
// ─── Internal helpers ──────────────────────────────────────────────────────────
/**
* Returns true if `slice` has a non-empty type, a `default` property, and a
* non-empty string description. Does NOT throw.
*/
function _isWellFormedSlice(slice: unknown): slice is ConfigSliceEntry {
if (typeof slice !== 'object' || slice === null || Array.isArray(slice)) return false;
const s = slice as Record<string, unknown>;
if (typeof s['type'] !== 'string' || s['type'].length === 0) return false;
if (!VALID_SLICE_TYPES.has(s['type'])) return false;
if (!Object.prototype.hasOwnProperty.call(s, 'default')) return false;
return true;
}
/**
* Returns true if `value` matches the declared type in the slice.
* For enum, also validates against slice.values if present.
*/
function _typeMatches(value: unknown, slice: ConfigSliceEntry): boolean {
switch (slice.type) {
case 'boolean': return typeof value === 'boolean';
case 'string': return typeof value === 'string';
case 'number': return typeof value === 'number';
case 'enum':
// Must be a string AND, if values list is present, must be in it
if (typeof value !== 'string') return false;
if (Array.isArray(slice.values) && slice.values.length > 0) {
return slice.values.includes(value);
}
return true;
default: return false;
}
}
/**
* Traverse a dotted key path through a nested config object.
* E.g. key="workflow.ui_phase", obj={workflow:{ui_phase:false}} → {found:true, value:false}
* Returns {found:false} if any segment is missing or not an own property.
* Handles 1, 2, or N segments generically.
*/
function _getNestedValue(obj: Record<string, unknown>, key: string): { found: boolean; value: unknown } {
const segments = key.split('.');
let current: unknown = obj;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
// Inline literal prototype-pollution guard
if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') {
return { found: false, value: undefined };
}
if (typeof current !== 'object' || current === null) {
return { found: false, value: undefined };
}
const cur = current as Record<string, unknown>;
if (!Object.prototype.hasOwnProperty.call(cur, seg)) {
return { found: false, value: undefined };
}
current = cur[seg];
}
return { found: true, value: current };
}
// ─── Public API ────────────────────────────────────────────────────────────────
/**
* Defensive merge of capability-declared config slices into the loadConfig
* return value.
*
* DEFENSIVE contract (never throws, even on bad capability data):
* - Null/undefined/non-object input → returns empty result.
* - Null/undefined/non-object userConfig → treated as {} (no overrides).
* - Central keys are skipped with a pending-migration warning.
* - Malformed slices are skipped with a warning.
* - User-supplied values with wrong types (or out-of-enum values) fall back
* to the slice default (a type-mismatch warning is pushed but the key is
* still federated with its default; this is best-effort degraded operation).
*/
function mergeFederatedConfig(input: MergeFederatedConfigInput): MergeFederatedConfigResult {
// FIX 4: Guard null/undefined/non-object input
if (input === null || input === undefined || typeof input !== 'object') {
return { values: Object.create(null) as Record<string, unknown>, validKeys: [], warnings: [] };
}
const { configSchema, isCentralKey } = input;
// FIX 4: Guard null/undefined/non-object userConfig — treat as {}
const userConfig: Record<string, unknown> =
(input.userConfig !== null && input.userConfig !== undefined && typeof input.userConfig === 'object' && !Array.isArray(input.userConfig))
? input.userConfig
: {};
// FIX 6b: Use null-prototype object for all return paths
const values: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
const validKeys: string[] = [];
const warnings: string[] = [];
if (typeof configSchema !== 'object' || configSchema === null) {
return { values: Object.create(null) as Record<string, unknown>, validKeys: [], warnings: [] };
}
for (const key of Object.keys(configSchema)) {
// S2: inline literal prototype-pollution guard (CodeQL barrier)
// Guard both the full key AND all dotted-path segments
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
const _keySegments = key.split('.');
if (_keySegments.some((s) => s === '__proto__' || s === 'constructor' || s === 'prototype')) continue;
const slice = configSchema[key];
// If this key is still in the central schema → pending migration, skip
try {
if (isCentralKey(key)) {
warnings.push(
'federated-config: key "' + key + '" is still in the central config-schema (pending-migration); ' +
'skipping federated resolution until the central schema entry is removed',
);
continue;
}
} catch {
// isCentralKey threw — treat as unknown, skip defensively
warnings.push('federated-config: isCentralKey("' + key + '") threw; skipping key');
continue;
}
// Validate slice shape — skip malformed entries
if (!_isWellFormedSlice(slice)) {
warnings.push(
'federated-config: config slice for key "' + key + '" is malformed (missing or invalid type/default); skipping',
);
continue;
}
const sliceEntry = slice;
// FIX 1: Resolve value using NESTED dotted-path lookup through userConfig
let resolvedValue: unknown = sliceEntry.default;
const { found: userHasKey, value: userValue } = _getNestedValue(userConfig, key);
if (userHasKey && userValue !== undefined) {
// FIX 5b: For enum, validate against slice.values if present; otherwise check type
if (_typeMatches(userValue, sliceEntry)) {
resolvedValue = userValue;
} else {
const typeDesc = sliceEntry.type === 'enum' && Array.isArray(sliceEntry.values)
? 'enum(' + sliceEntry.values.join('|') + ')'
: sliceEntry.type;
warnings.push(
'federated-config: user-supplied value for "' + key + '" has wrong type or invalid enum value ' +
'(expected ' + typeDesc + ', got ' + typeof userValue +
(typeof userValue === 'string' ? ' "' + String(userValue) + '"' : '') +
'); falling back to slice default',
);
// resolvedValue stays as slice default
}
}
// S2: inline literal guard before writing to values
if (key !== '__proto__' && key !== 'constructor' && key !== 'prototype') {
values[key] = resolvedValue;
validKeys.push(key);
}
}
return { values, validKeys, warnings };
}
export = { mergeFederatedConfig };