# Tasks: enterprise-config **Change ID**: enterprise-config **Package**: `@suaveplan/config` **Target path**: `universal/config/config` **Prerequisite packages on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/env`, `@suaveplan/feature-flags` --- ## 1. Scaffold - [x] Create directory `universal/config/config/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`. - [x] Write `package.json` at version `0.1.0` with: - `"type": "module"`, `"sideEffects": false` - `exports` map with a top-level `"."` barrel **plus** one subpath per feature module (`"./constants"`, `"./types/common"`, `"./core/config-builder/config-builder"`, `"./core/config-store/config-store"`, `"./layers/defaults-layer/defaults-layer"`, `"./layers/object-layer/object-layer"`, `"./layers/env-layer/env-layer"`, `"./layers/file-layer/file-layer"`, `"./layers/cli-layer/cli-layer"`, `"./utils/merge"`, `"./utils/mask"`) - `dependencies`: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/events workspace:^`, `@suaveplan/env workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`, `tslib catalog:build` - `devDependencies`: `@suaveplan/feature-flags workspace:^`, `vitest catalog:testing`, `@vitest/coverage-istanbul catalog:testing`, `fast-check catalog:testing`, `mitata catalog:testing`, `@suaveplan/testing workspace:^` - [x] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`. - [x] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config` (pool: `forks`). - [x] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts to `package.json`. - [x] Register the package in the root `bun.lockb` workspace by adding `"universal/config/config"` to the workspace globs (if not already covered by `universal/*/*`). --- ## 2. Types + errors ### 2a. Types (`src/types/common.ts` + `src/types/common.md`) - [x] Define all public TS interfaces and type aliases (preserved from main branch): - `ConfigData`, `ConfigLayer`, `ConfigValidator`, `EnvironmentOverrides`, `CreateConfigOptions`, `ConfigStore`, `ConfigStoreOptions`, `EnvLayerOptions`, `CliLayerOptions`, `FileLayerOptions` - [x] Add genesis-only optional fields to `CreateConfigOptions`: `logger?: LoggerLike` (import from `@suaveplan/types`). - [x] Add genesis-only optional fields to `FileLayerOptions`: `watchBus?: EventBusLike`, `watchEvent?: string`. - [x] Add genesis-only optional methods to `ConfigStore`: `reload(signal?: AbortSignal): Promise`, `subscribe(listener: (next: Readonly) => void): () => void`. - [x] Write `src/types/common.md` (≥200 words, both simple + advanced examples, API reference for every exported type). ### 2b. Error codes (`src/error-codes.ts`) - [x] Import `CodeMetadata` and `ErrorCodes` from `@suaveplan/error`. - [x] Define `ConfigPackageErrorCode` union — seven codes: - `CONFIG_MISSING`, `CONFIG_INVALID_TYPE`, `CONFIG_PARSE_FAILED`, `CONFIG_FILE_NOT_FOUND`, `CONFIG_ENV_NOT_SET` (preserved from main), plus `CONFIG_RELOAD_FAILED`, `CONFIG_VALIDATION_FAILED` (genesis additions). - [x] Define `CONFIG_ERROR_METADATA` constant with `httpStatus`, `severity`, `retryable`, `userMessageKey`, `category`, `description` for each code. - [x] Export `registerConfigErrorCodes()` — calls `ErrorCodes.registerMany(CONFIG_ERROR_METADATA)`. Idempotent. - [x] Call `registerConfigErrorCodes()` as an eager side-effect at the bottom of the file. - [x] Re-export `ConfigError` from `@suaveplan/error` (not a local sub-class). ### 2c. Zod option schemas (`src/types/schemas.ts` + `src/types/schemas.md`) - [x] Write Zod schemas for every public options interface: - `EnvLayerOptionsSchema`, `CliLayerOptionsSchema`, `FileLayerOptionsSchema` - `ConfigStoreOptionsSchema` - `CreateConfigOptionsSchema` (layers validated as `z.array(z.object({ name: z.string(), priority: z.number() }))` — the `resolve` function is not Zod-validated but the shape is checked) - [x] Export inferred types as `z.infer` companions where they diverge from the hand-written types. - [x] Write `src/types/schemas.md`. --- ## 3. Core utilities ### 3a. Path utilities (`src/utils/mask.ts` + `src/utils/mask.md`) - [x] Implement `getByPath(obj, path)` — splits on `"."`, walks object chain, returns `undefined` on missing segment. - [x] Implement `setByPath(obj, path, value)` — creates intermediate objects, mutates in place, returns `obj`. - [x] Implement `hasByPath(obj, path)` — returns `getByPath(obj, path) !== undefined`. - [x] Implement `maskSensitive(data, sensitive, mask?)` — `structuredClone` then `setByPath` each sensitive path. - [x] Export `MASK_STRING` re-export from constants. - [x] Write `src/utils/mask.test.ts` (≤500 lines): happy path, nested paths, missing paths, array-valued paths, multi-sensitive mask, custom mask string. Every branch covered. - [x] Write `src/utils/mask.md` (≥200 words, API reference, examples). ### 3b. Merge utilities (`src/utils/merge.ts` + `src/utils/merge.md`) - [x] Implement `deepMerge(base, override)`: - Plain objects recurse; arrays are replaced; `undefined` values in override are skipped; all other types overwrite. - No dependency on `@suaveplan/utils` (genesis does not have that package yet — implement natively; ≤60 lines). - [x] Implement `mergeLayers(layers)` — iterates sorted array, accumulates with `deepMerge`. - [x] Write `src/utils/merge.test.ts` (≤500 lines): primitive override, nested merge, array replace, undefined skip, N-layer merge, empty layers array. - [x] Write `src/utils/merge.md` (≥200 words, API reference, examples). --- ## 4. Layer implementations ### 4a. Defaults layer (`src/layers/defaults-layer/`) - [x] Implement `defaultsLayer(defaults: T): ConfigLayer` — priority `LAYER_PRIORITIES.DEFAULTS` (0), `resolve` returns shallow copy. - [x] Write `defaults-layer.test.ts`: returns copy (mutation safety), correct priority, correct name. - [x] Write `defaults-layer.md`. ### 4b. Object layer (`src/layers/object-layer/`) - [x] Implement `objectLayer(data, priority?)` — default priority `LAYER_PRIORITIES.OBJECT` (20), `resolve` returns shallow copy. - [x] Write `object-layer.test.ts`: custom priority, default priority, mutation safety. - [x] Write `object-layer.md`. ### 4c. Env layer (`src/layers/env-layer/`) - [x] Implement `envLayer(options: EnvLayerOptions): ConfigLayer` — priority `LAYER_PRIORITIES.ENV` (30). - Source defaults to `process.env` when running in Node/Bun; falls back to `{}` in browser environments. - Apply optional `prefix` stripping: prepend prefix to each key before lookup. - For each `mapping` entry, call `coerceValue` on the raw string and write via `setByPath`. - Delegate prefix stripping and coercion to `@suaveplan/env` internals where the API allows; fall back to local `coerceValue` for paths `@suaveplan/env` does not expose. - [x] Export `coerceValue(raw: string): unknown` — thin wrapper that handles the seven cases: empty string, boolean-like strings (`true/1/yes/on/enabled`, `false/0/no/off/disabled`), numeric strings, JSON object/array strings, everything else stays string. - [x] Write `env-layer.test.ts`: prefix, mapping, source override, coercion for each type, missing keys ignored, empty mapping. - [x] Write `env-layer.md`. ### 4d. File layer (`src/layers/file-layer/`) - [x] Implement `fileLayer(options: FileLayerOptions): ConfigLayer` — priority `LAYER_PRIORITIES.FILE` (10). - `resolve` returns `{ ...options.content }`. - If `options.watchBus` is provided, subscribe to `options.watchEvent ?? 'config:file:changed'` on the bus; on receipt, call the parent store's `reload()` if the layer has been registered with a reloadable store. Store the unsubscribe handle and expose a `teardown()` method on the returned layer (added to the interface via optional `teardown?(): void` on `ConfigLayer`). - File I/O remains the caller's responsibility — the layer receives pre-parsed `content`; the watch mechanism only signals re-resolution. - [x] Write `file-layer.test.ts`: static path (no bus), watch path (mock EventBus via real `@suaveplan/events` EventBus, not vi.mock), teardown unsubscribes. - [x] Write `file-layer.md`. ### 4e. CLI layer (`src/layers/cli-layer/`) - [x] Implement `parseArgs(args, mapping)` — handles `--key=value`, `--key value`, `--flag` (boolean true), `-k value` (short flags). Unknown flags silently ignored. - [x] Implement `cliLayer(options: CliLayerOptions): ConfigLayer` — priority `LAYER_PRIORITIES.CLI` (40), calls `parseArgs` then `coerceValue` + `setByPath` for each matched flag. - [x] Write `cli-layer.test.ts`: `--key value`, `--key=value`, `--flag` (bool), `-k value`, unknown flags ignored, empty args. - [x] Write `cli-layer.md`. --- ## 5. ConfigStore ### 5a. Static store (`src/core/config-store/config-store.ts`) - [x] Implement `createConfigStore(options: ConfigStoreOptions): ConfigStore`: - `structuredClone` + `deepFreeze` the data. - `get(key)` — `getByPath(frozenData, key)`. - `has(key)` — `hasByPath(frozenData, key)`. - `toJSON()` — returns `frozenData`. - `toMasked()` — `deepFreeze(maskSensitive(frozenData, sensitiveKeys))`. - `reload()` — returns `Promise.resolve()` (no-op; live reload is wired by `createConfig`). - `subscribe(listener)` — no-op stub that returns a no-op unsubscribe (live subscriptions wired by `createConfig`). - [x] Write `config-store.test.ts`: get/has/toJSON/toMasked, deeply nested key, missing key returns undefined, frozen data cannot be mutated externally, sensitive masking with multiple paths. - [x] Write `config-store.md`. ### 5b. Live store wiring (inside `createConfig` orchestrator) - [x] When `createConfig` is called with layers that include at least one with `resolve` (all layers do), wire a real `reload` implementation: - Accepts optional `AbortSignal`; if signal is already aborted before starting, throw `ConfigError` with code `CONFIG_RELOAD_FAILED`. - Re-runs layer resolution + environment overrides + validation. - On success, swaps internal `frozenData` reference and notifies all `subscribe` listeners. - Throws `ConfigError` with code `CONFIG_RELOAD_FAILED` on layer or validation error. - [x] Wire `subscribe` to maintain a `Set` returned by the live store closure; unsubscribe removes from set. - [x] Instrument `reload` with an OTel span `config.reload` and a counter `config.reload_total`. --- ## 6. `createConfig` orchestrator File: `src/core/config-builder/config-builder.ts` - [x] Implement `resolveLayers(options)`: - Iterate `options.layers`, call `layer.resolve()` in a try/catch, collect `ResolvedLayer[]`. - Sort ascending by `priority`. - Fold with `deepMerge` to produce `MergeResult`. - On layer error, throw `ConfigError` with code `CONFIG_PARSE_FAILED`, wrapping the cause. - [x] Implement `applyEnvironment(data, options)`: - If `options.environment` and `options.environments` exist, look up the override and `deepMerge` it on top. - [x] Implement `createConfig(options)`: 1. Log start via `options.logger?.debug('config:create:start', { layerCount: options.layers.length })`. 2. Call `resolveLayers`. 3. Call `applyEnvironment`. 4. Call `options.validate(merged)` if provided; on throw, wrap in `ConfigError` with code `CONFIG_VALIDATION_FAILED`. 5. Build the live store (with wired reload/subscribe). 6. Log success via `options.logger?.debug('config:create:done')`. 7. Return the store. - [x] Add internal `layerError` and `validationFailed` factory helpers so error construction is never stringly-typed at the call site. - [x] Write `config-builder.test.ts` (≤500 lines): happy path with multiple layers, env override applied, validate called and throws, layer resolution throws, sensitive masking end-to-end, logger receives expected calls, reload re-resolves layers, subscribe notified on reload. - [x] Write `config-builder.md`. - [x] Write `config-builder.types.ts` exporting `ResolvedLayer`, `MergeResult`, `ConfigErrorDetail`. --- ## 7. OTel instrumentation - [x] Import `trace`, `metrics` from `@opentelemetry/api`; obtain tracer (`config`) and meter (`config`) once at module scope (lazy via `trace.getTracer` / `metrics.getMeter`). - [x] `createConfig`: wrap execution in `tracer.startActiveSpan('config.create', ...)`. Set span attributes `config.layer_count` and `config.environment` (string). Record exception on error; end span in `finally`. - [x] `store.get`: increment counter `config.get_total` with attribute `config.hit` (`true` if `hasByPath` returns true). - [x] `store.reload`: wrap in span `config.reload`; increment counter `config.reload_total`; record duration as histogram `config.reload_duration_ms`. - [x] `mergeLayers`: record duration as histogram `config.merge_duration_ms` with attribute `config.layer_count`. - [x] All OTel calls must be guarded so they degrade gracefully when no SDK is installed (the `@opentelemetry/api` no-op implementation handles this automatically — no extra guarding needed beyond not calling `.start()` outside a span context). --- ## 8. Property-based tests (`tests/property.test.ts`) Use `fast-check` (`fc`). All test timeouts: `{ timeout: 30000 }`. - [x] **`deepMerge` associativity**: for arbitrary plain-object triples `(a, b, c)`, assert `deepMerge(deepMerge(a, b), c)` equals `deepMerge(a, deepMerge(b, c))`. Constraint: inputs contain no `undefined` values (undefined-skip rule makes strict associativity hold only without undefined). - [x] **Layer priority ordering**: generate N random layers (2–8) with random priority values; assert that `mergeLayers` applied to the full list equals iterating the list sorted ascending by priority and folding with `deepMerge`. - [x] **`setByPath` / `getByPath` round-trip**: for arbitrary dot-path strings (1–4 segments, alphanum only) and arbitrary leaf values (`fc.jsonValue()`), assert `getByPath(setByPath({}, path, v), path) === v`. - [x] **`maskSensitive` idempotency**: for arbitrary config objects and arbitrary arrays of dot-paths, assert `maskSensitive(maskSensitive(data, paths), paths)` equals `maskSensitive(data, paths)`. - [x] **`coerceValue` stability**: for numeric, boolean, and object inputs, serialise to string via `String()` / `JSON.stringify()`, call `coerceValue`, assert result deep-equals the original value. - [x] **`parseArgs` completeness**: for arbitrary `--key value` pairs, assert that every supplied key appears in the output with the correct raw string value (assumes all keys are in the mapping). --- ## 9. Benchmarks (`src/__benchmarks__/config.bench.ts`) Use `mitata`. No test timeout assertions — benchmarks are informational. - [x] Benchmark `createConfig_cold`: `createConfig` with 5 layers (defaults + file + object + env + cli), no validation. - [x] Benchmark `store_get_hot`: 100 `store.get()` calls on a deeply nested key (`'a.b.c.d'`) against a pre-built store. - [x] Benchmark `mergeLayers_2`, `mergeLayers_5`, `mergeLayers_10`, `mergeLayers_20`: `mergeLayers` with increasing layer counts (each layer is a flat 10-key object). - [x] Add a `bench` script to `package.json`: `"bench": "vitest bench"`. - [x] Document benchmark numbers (placeholder row) in the package `README.md` under a `## Benchmarks` section — actual numbers are filled in after the first green run. --- ## 10. Docs ### Per-file `.md` (all must be ≥200 words, both simple + advanced examples, API reference) - [x] `src/types/common.md` - [x] `src/types/schemas.md` - [x] `src/core/config-builder/config-builder.md` - [x] `src/core/config-store/config-store.md` - [x] `src/layers/defaults-layer/defaults-layer.md` - [x] `src/layers/object-layer/object-layer.md` - [x] `src/layers/env-layer/env-layer.md` - [x] `src/layers/file-layer/file-layer.md` - [x] `src/layers/cli-layer/cli-layer.md` - [x] `src/utils/merge.md` - [x] `src/utils/mask.md` ### Root README - [x] Write `universal/config/config/README.md` (package root only — no category-level READMEs inside `src/`): - Purpose, installation, quick-start (5-layer `createConfig` example) - Table linking each co-located `.md` - `## Benchmarks` table with placeholder row - Dependencies table - `## API` section listing every exported symbol ### `docs:check` script - [x] Add `"docs:check": "..."` script (e.g. `bunx typedoc --validation` or a custom checker) consistent with other genesis packages; confirm it passes before the verify step. --- ## 11. Verify and publish - [x] Run `bunx turbo lint --filter=@suaveplan/config` — zero errors. - [x] Run `bunx turbo typecheck --filter=@suaveplan/config` — zero errors. - [x] Run `bunx turbo test --filter=@suaveplan/config` — all tests pass (unit + integration + property). - [x] Run `bunx turbo test:coverage --filter=@suaveplan/config` — statements/branches/functions/lines all 100%. If any module is below threshold, add missing test cases; do NOT lower the threshold. - [x] Run `bun run docs:check --filter=@suaveplan/config` — zero errors. - [x] Confirm no `TODO`, `FIXME`, `@deprecated`, untyped `any`, `ts-ignore`, or `.skip` remain in `src/` or `tests/`. - [x] Confirm `"sideEffects": false` is set in `package.json`. - [x] Run `bun scripts/publish.ts --package @suaveplan/config`. - [x] Update `CLAUDE.md` package listing to include `@suaveplan/config`. - [x] Archive this change: move `openspec/changes/enterprise-config/` to `openspec/archive/2026-04-23-enterprise-config/` and fold the spec delta into `openspec/specs/config/spec.md`.