Files
suaveplan/openspec/archive/2026-04-24-enterprise-config/proposal.md
2026-05-27 23:29:53 +01:00

12 KiB

OpenSpec Change: enterprise-config

  • Change ID: enterprise-config
  • Package: @suaveplan/config
  • Category: universal
  • Target path: universal/config/config
  • Status: IN PROGRESS
  • Date: 2026-04-23
  • Reference spec: /mnt/s/web/suaveplan/enterprise/config.md

Summary

Deliver @suaveplan/config on the genesis branch from scratch at enterprise quality. The package provides layered configuration management: a set of typed layers (defaults, file, object, env, CLI) are resolved in priority order, deep-merged, optionally validated via a caller-supplied Zod-compatible schema, and exposed through an immutable ConfigStore. Genesis adds structured errors via @suaveplan/error, Zod option schemas, OTel instrumentation, AbortSignal on async reload, event-bus change propagation via @suaveplan/events, delegation of env-var coercion to @suaveplan/env, property-based tests (fast-check), and a mitata benchmark suite.


Current-state audit (genesis)

The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data.


Available workspace deps on genesis

Package Role
@suaveplan/error (workspace:^) Base class for ConfigError; defineCodes / ErrorCodes.registerMany for CONFIG.* codes
@suaveplan/types (workspace:^) LoggerLike, EventBusLike, Clock (injectable clock for future reload scheduling)
@suaveplan/events (workspace:^) EventBus for file-layer watch-mode change notifications
@suaveplan/env (workspace:^) createEnv / field descriptors — used inside envLayer for enterprise-grade coercion and prefix stripping
@suaveplan/feature-flags (workspace:^) FlagManager — ConfigStore can optionally accept a flag manager to gate experimental read paths
zod (catalog:forms) Zod schemas for all public option types
@opentelemetry/api (catalog:observability) Spans + counters on createConfig, store.get, store.set, store.reload
tslib (catalog:build) Runtime helpers for TS decorators (if needed)

Dev deps: vitest, @vitest/coverage-istanbul, fast-check, mitata (all catalog:testing).


Preserved public API surface (from main branch)

All symbols below are carried forward verbatim; signatures may gain optional genesis-only fields but the existing call sites remain valid without changes.

Constants

LAYER_PRIORITIES: { DEFAULTS: 0; FILE: 10; OBJECT: 20; ENV: 30; CLI: 40 }
MASK_STRING: "****"

Core

// Re-exported from @suaveplan/error
class ConfigError extends SuaveplanError

// Orchestrator — resolves layers, merges, applies env overrides, validates
function createConfig<T extends ConfigData>(options: CreateConfigOptions<T>): ConfigStore<T>

// Internal types surfaced for downstream consumers
interface ConfigErrorDetail { path?: string; message: string }
interface MergeResult      { data: ConfigData; layers: ResolvedLayer[] }
interface ResolvedLayer    { name: string; priority: number; data: ConfigData }

Store

function createConfigStore<T extends ConfigData>(options: ConfigStoreOptions<T>): ConfigStore<T>

interface ConfigStoreOptions<T extends ConfigData = ConfigData> {
  data: T
  sensitive: string[]
}

Layers

function defaultsLayer<T extends ConfigData>(defaults: T): ConfigLayer
function objectLayer(data: ConfigData, priority?: number): ConfigLayer
function envLayer(options: EnvLayerOptions): ConfigLayer
function fileLayer(options: FileLayerOptions): ConfigLayer
function cliLayer(options: CliLayerOptions): ConfigLayer   // wraps parseArgs internally
function parseArgs(args: string[], mapping: Record<string, string>): Record<string, string>

Types

type ConfigData = Record<string, unknown>

interface ConfigLayer {
  readonly name: string
  readonly priority: number
  resolve(): ConfigData
}

type ConfigValidator<T extends ConfigData = ConfigData> = (data: ConfigData) => T

type EnvironmentOverrides<T extends ConfigData = ConfigData> = Record<string, Partial<T>>

interface CreateConfigOptions<T extends ConfigData = ConfigData> {
  layers: ConfigLayer[]
  validate?: ConfigValidator<T>
  sensitive?: string[]
  environments?: EnvironmentOverrides<T>
  environment?: string
}

interface ConfigStore<T extends ConfigData = ConfigData> {
  get<K extends string>(key: K): unknown
  has(key: string): boolean
  toJSON(): Readonly<T>
  toMasked(): Readonly<T>
}

interface EnvLayerOptions {
  prefix?: string
  mapping: Record<string, string>
  source?: Record<string, string | undefined>
}

interface CliLayerOptions {
  args: string[]
  mapping: Record<string, string>
}

interface FileLayerOptions {
  content: ConfigData
}

Utils

function deepMerge<T extends ConfigData>(base: T, override: Partial<T>): T
function mergeLayers(layers: Array<{ name: string; data: ConfigData }>): ConfigData
function getByPath(obj: ConfigData, path: string): unknown
function hasByPath(obj: ConfigData, path: string): boolean
function setByPath(obj: ConfigData, path: string, value: unknown): ConfigData
function maskSensitive(data: ConfigData, sensitive: string[], mask?: string): ConfigData
function coerceValue(raw: string): unknown

Error codes

type ConfigPackageErrorCode =
  | "CONFIG_MISSING"
  | "CONFIG_INVALID_TYPE"
  | "CONFIG_PARSE_FAILED"
  | "CONFIG_FILE_NOT_FOUND"
  | "CONFIG_ENV_NOT_SET"

const CONFIG_ERROR_METADATA: Readonly<Record<ConfigPackageErrorCode, CodeMetadata>>
function registerConfigErrorCodes(): void

New additions for genesis

1. Extended error codes

Two additional codes beyond the main-branch set:

Code Severity Retryable Trigger
CONFIG_RELOAD_FAILED error true Reload aborted or layer re-resolution threw
CONFIG_VALIDATION_FAILED fatal false Validator threw (previously mapped to CONFIG_INVALID which does not exist in the registry)

2. Zod option schemas

Zod schemas (not just TS types) for every public options object so callers can validate config at the boundary:

  • CreateConfigOptionsSchema
  • ConfigStoreOptionsSchema
  • EnvLayerOptionsSchema
  • CliLayerOptionsSchema
  • FileLayerOptionsSchema

3. Structured ConfigError

ConfigError extends SuaveplanError from @suaveplan/error (not a local sub-class), carrying a ConfigPackageErrorCode as its .code discriminant. All internal throw sites use named factory helpers so codes are never stringly-typed at the call site.

4. envLayer delegates to @suaveplan/env

Genesis envLayer wraps @suaveplan/env's createEnv internally for prefix stripping and type coercion instead of duplicating the logic. The public EnvLayerOptions interface remains stable; coerceValue is still exported from the package as a utility (it delegates to @suaveplan/env's internals).

5. ConfigStore enhancements

The ConfigStore interface gains two optional genesis additions (both present on the concrete returned object but typed as optional on the interface to remain backward-compatible):

interface ConfigStore<T extends ConfigData = ConfigData> {
  // --- existing ---
  get<K extends string>(key: K): unknown
  has(key: string): boolean
  toJSON(): Readonly<T>
  toMasked(): Readonly<T>
  // --- genesis additions ---
  reload(signal?: AbortSignal): Promise<void>   // re-resolves all layers; no-op on static stores
  subscribe(listener: (next: Readonly<T>) => void): () => void  // returns unsubscribe fn
}

createConfigStore is kept synchronous and static; reload / subscribe are only wired when createConfig constructs the store with a live layer set.

6. fileLayer watch mode

fileLayer accepts an optional EventBus from @suaveplan/events. When provided and the layer is attached to a reloadable ConfigStore, a file-change signal from the bus triggers a reload cycle. This is opt-in; the sync path is unchanged.

interface FileLayerOptions {
  content: ConfigData
  // genesis addition — optional watch support
  watchBus?: EventBusLike
  watchEvent?: string   // event name to listen for; default: 'config:file:changed'
}

7. LoggerLike injection into createConfig

CreateConfigOptions accepts an optional logger?: LoggerLike (from @suaveplan/types). When present, layer resolution counts, merge durations, and validation outcomes are written as structured log entries (not OTel — OTel is additive).

interface CreateConfigOptions<T extends ConfigData = ConfigData> {
  // ... existing fields ...
  logger?: LoggerLike   // genesis addition
}

8. OTel instrumentation

  • createConfig — span config.create, attributes: config.layer_count, config.environment
  • store.get — counter config.get_total, attribute config.hit (bool)
  • store.reload — span config.reload, counter config.reload_total
  • mergeLayers — histogram config.merge_duration_ms

9. Property-based tests (fast-check)

  • deepMerge associativity: merge(merge(a, b), c) ≡ merge(a, merge(b, c)) for plain-object inputs
  • Layer priority ordering: result of mergeLayers with N random layers equals result when layers are sorted by priority then merged left-to-right
  • setByPath / getByPath round-trip: getByPath(setByPath({}, path, v), path) === v for any valid dot-path
  • maskSensitive idempotency: applying mask twice yields the same output as applying once
  • coerceValue stability: calling coerceValue on the string representation of a non-string output returns the same value

10. Benchmark suite (src/__benchmarks__/)

  • createConfig with 5 layers (cold path)
  • store.get (hot path, deeply nested key)
  • mergeLayers with 2, 5, 10, 20 layers

File layout

universal/config/config/
  package.json
  tsconfig.json
  vitest.config.ts
  src/
    index.ts                        # Public barrel
    error-codes.ts                  # CONFIG.* codes + registerConfigErrorCodes
    constants/
      index.ts                      # LAYER_PRIORITIES, MASK_STRING, TRUTHY_STRINGS, FALSY_STRINGS
    types/
      common.ts                     # All public TS interfaces and type aliases
      common.md
    core/
      config-builder/
        config-builder.ts           # createConfig orchestrator
        config-builder.md
        config-builder.types.ts     # ResolvedLayer, MergeResult, ConfigErrorDetail
        config-builder.test.ts
        index.ts
      config-store/
        config-store.ts             # createConfigStore
        config-store.md
        config-store.types.ts       # ConfigStoreOptions
        config-store.test.ts
        index.ts
    layers/
      defaults-layer/
        defaults-layer.ts
        defaults-layer.md
        defaults-layer.test.ts
        index.ts
      object-layer/
        object-layer.ts
        object-layer.md
        object-layer.test.ts
        index.ts
      env-layer/
        env-layer.ts                # envLayer + coerceValue (delegates to @suaveplan/env)
        env-layer.md
        env-layer.test.ts
        index.ts
      file-layer/
        file-layer.ts               # fileLayer + optional watchBus support
        file-layer.md
        file-layer.test.ts
        index.ts
      cli-layer/
        cli-layer.ts                # cliLayer + parseArgs
        cli-layer.md
        cli-layer.test.ts
        index.ts
    utils/
      merge.ts                      # deepMerge, mergeLayers
      merge.md
      merge.test.ts
      mask.ts                       # getByPath, hasByPath, setByPath, maskSensitive
      mask.md
      mask.test.ts
    __benchmarks__/
      config.bench.ts
  tests/
    integration.test.ts             # cross-cutting: full createConfig → store round-trip
    property.test.ts                # fast-check property tests

Impact

Downstream packages that will consume @suaveplan/config: any server-side or CLI package that needs structured config (e.g. future @suaveplan/server, API plugins, CLI tools built on @suaveplan/cli). No existing genesis package currently depends on it — this is a forward dependency.

Breaking changes from main-branch API: none. All main-branch call sites compile unchanged. New optional fields (logger, watchBus, watchEvent, reload, subscribe) are additive.

Workspace dep prerequisites: @suaveplan/error, @suaveplan/types, @suaveplan/events, @suaveplan/env, @suaveplan/feature-flags must all be published to Verdaccio before this package can be built or tested.