Skip to content

Typed config section adapters for module extraction

Authors
Matt Cockayne, Codex (AI drafting assistant)
Date
7 July 2026
Status
IMPLEMENTED
Builds on
2026-07-07-slog-first-extraction-seams.md
Related
2026-07-07-package-extraction-report.md

1. Context

pkg/config is one of GTB's core framework packages. It wraps Viper with file/env/default precedence, afero-backed loading, env-prefix binding, multi-file merging, embedded defaults, hot reload, observers, schema validation, pflag binding, and testable filesystem behaviour.

Many packages proposed for extraction currently depend on pkg/config directly or indirectly. That makes it tempting to extract pkg/config first and use it as the shared configuration abstraction for every future module.

That would solve one coupling problem by creating another. Standalone modules should not have to import GTB's full runtime config stack just to receive a few settings. A package that needs a timeout, endpoint, token reference, or TLS paths should not care whether the host application uses GTB config, envconfig, Koanf, JSON, flags, Kubernetes config maps, or hard-coded test values.

This spec defines the decoupling pattern:

  • extracted packages own typed configuration structs,
  • GTB's config container unmarshals resolved sections into those structs,
  • GTB adapter code performs existence checks, default merging, validation, and credential-resolution wiring,
  • extracted packages receive typed values and remain config-system agnostic.

2. Problem Statement

config.Containable is useful inside GTB but too broad as a dependency seam for extracted modules.

Current Containable includes:

type Containable interface {
    Get(key string) any
    GetBool(key string) bool
    GetInt(key string) int
    GetFloat(key string) float64
    GetString(key string) string
    GetTime(key string) time.Time
    GetDuration(key string) time.Duration
    GetViper() *viper.Viper
    BindPFlag(key string, flag *pflag.Flag) error
    Has(key string) bool
    IsSet(key string) bool
    Set(key string, value any)
    WriteConfigAs(dest string) error
    ConfigFiles() []string
    Sub(key string) Containable
    AddObserver(o Observable)
    AddObserverFunc(f func(Containable) error)
    OnReloadError(f func(error))
    ToJSON() string
    Dump(w io.Writer)
    Validate(schema *Schema) *ValidationResult
}

Most extraction candidates need only a small typed subset of this information. If they import pkg/config, they also inherit Viper and GTB-specific runtime behaviour.

3. Goals

  • Make typed package-owned config structs the primary configuration boundary for reusable and extracted packages.
  • Add first-class config-container APIs for unmarshalling resolved config sections into typed structs.
  • Add first-class observer-backed binding APIs so GTB adapters can rehydrate typed settings automatically on successful config reloads.
  • Preserve GTB's config precedence and env-prefix semantics in adapter code.
  • Make section existence explicit so adapters can distinguish absent config, present-but-empty config, and invalid config.
  • Keep typed package-owned structs the primary boundary so modules can be built without pkg/config, and do not require it. Note that pkg/config is itself an early planned extraction (it already works standalone); once it is its own lightweight module, a package may import it directly where there is a genuine use case. The binding constraint is severing coupling to GTB's main-module weight — chiefly pkg/props and the composition layer — not to pkg/config itself.
  • Provide a package-by-package migration plan for extraction candidates.
  • Treat documentation as a first-class deliverable for every migrated package, including component docs, concept docs, migration notes, and generated-project guidance where applicable.
  • Keep pkg/config extractable later as its own optional CLI config module, but not as a prerequisite or universal abstraction for other modules.

4. Non-goals

  • Do not extract pkg/config in this spec.
  • Do not remove existing config.Containable APIs immediately.
  • Do not force every package to use config structs if the package has no config.
  • Do not make GTB's main module — chiefly pkg/props and the composition glue — a dependency of extracted modules. pkg/config is exempt: it is itself an early extraction candidate and an acceptable lightweight dependency, so this spec does not forbid packages from importing it.
  • Do not change user-facing config keys in this spec unless a later package migration explicitly requires it.

5. Design Principles

5.1 Extracted modules own their config shape

The extracted module defines the struct it needs:

package chat

type OpenAIConfig struct {
    BaseURL string        `mapstructure:"base_url" json:"base_url" yaml:"base_url"`
    Model   string        `mapstructure:"model" json:"model" yaml:"model"`
    Timeout time.Duration `mapstructure:"timeout" json:"timeout" yaml:"timeout"`
}

func NewOpenAIClient(cfg OpenAIConfig, opts ...Option) (*OpenAIClient, error)

GTB owns how that struct is populated:

section, err := config.UnmarshalSection[chat.OpenAIConfig](props.Config, "openai")
if err != nil {
    return nil, err
}

client, err := chat.NewOpenAIClient(section.Value, chat.WithLogger(log))

This keeps the extracted module independent of GTB while still allowing GTB to provide its rich config system to applications built on the framework.

5.2 Typed options beat lookup interfaces

Lookup interfaces such as GetString(key string) are useful transitional seams, but they should not be the default extraction design. Typed config structs are preferable because they:

  • document the package's configuration contract in code,
  • allow package-specific validation,
  • avoid stringly-typed config key access inside extracted modules,
  • make tests simpler,
  • make default merging explicit,
  • allow non-GTB consumers to populate config any way they like.

Lookup interfaces remain acceptable for provider registries or compatibility adapters where dynamic provider-specific keys are unavoidable.

5.3 Existence is separate from value

Adapters often need to know whether a section exists, not just what values unmarshal from it. This matters for:

  • optional provider blocks,
  • "configured" checks in setup/init flows,
  • fallback-to-default behaviour,
  • preserving existing config during migration,
  • deciding whether a missing token is an error or merely means "try env".

The config API should expose this explicitly.

5.4 Config loading stays in GTB composition

Extracted modules should receive already-resolved typed values. GTB remains responsible for:

  • file paths,
  • embedded defaults,
  • environment variable precedence,
  • pflag binding,
  • config hot reload,
  • observer-backed rehydration of typed settings,
  • config file writes,
  • user-facing migration commands,
  • config schema validation at application startup.

6. Proposed Config API Additions

6.1 Section result type

Add a reusable result type to pkg/config:

type Section[T any] struct {
    Value  T
    Exists bool
}

Exists means the section or key is present in the resolved configuration view according to the chosen existence policy. The zero value means absent.

6.2 Unmarshal APIs

Add APIs to Containable and Container:

type Containable interface {
    // existing methods...
    Unmarshal(target any) error
    UnmarshalKey(key string, target any) error
    SectionExists(key string) bool
}

Add generic helpers:

func UnmarshalSection[T any](cfg Containable, key string) (Section[T], error)
func MustUnmarshalSection[T any](cfg Containable, key string) Section[T]

MustUnmarshalSection should be used sparingly, mostly in tests or package defaults where panics are acceptable. Most production code should use UnmarshalSection.

6.3 Observer-backed section binding

Long-lived GTB adapters must not perform a one-shot unmarshal and then leave runtime components with stale settings. Add a shared helper that performs the initial unmarshal and registers a config observer for future successful reloads:

type ObservedSection[T any] struct {
    // unexported snapshot/observer state
}

// Value returns the latest complete settings snapshot.
func (s *ObservedSection[T]) Value() T

// Current returns a pointer to the latest immutable settings snapshot.
// Callers must treat the returned value as read-only and must call Current
// again when they need to observe a later reload.
func (s *ObservedSection[T]) Current() *T

// Exists reports whether the latest snapshot came from an explicit section.
func (s *ObservedSection[T]) Exists() bool

// Version increments only when a successful reload changes the typed section.
func (s *ObservedSection[T]) Version() uint64

type SectionChange[T any] struct {
    Previous Section[T]
    Current  Section[T]
    Initial  bool
    Changed  bool
    Version  uint64
}

type SectionBindingOption[T any] func(*SectionBindingConfig[T])

func ObserveSection[T any](
    cfg Containable,
    key string,
    opts ...SectionBindingOption[T],
) (*ObservedSection[T], error)

ObserveSection is the default GTB adapter API for config-backed long-lived components. It must:

  1. unmarshal the initial resolved section,
  2. merge package defaults when configured by an option,
  3. validate the typed settings when configured by an option,
  4. store a complete immutable settings snapshot,
  5. register an observer with AddObserverFunc,
  6. rehydrate and validate a fresh snapshot after each successful reload,
  7. compare the previous and current typed snapshots as complete structs,
  8. atomically replace the published snapshot and increment the version only when the typed section changed, and
  9. invoke an optional apply callback with SectionChange[T] so components with live reload support can reconfigure immediately without hand-checking individual fields.

Recommended options:

func WithSectionDefaults[T any](defaults T, merge func(defaults, overlay T) T) SectionBindingOption[T]
func WithSectionDefaultFunc[T any](defaultFunc func(Containable) T, merge func(defaults, overlay T) T) SectionBindingOption[T]
func WithSectionValidator[T any](validate func(T) error) SectionBindingOption[T]
func WithSectionEqual[T any](equal func(previous, current Section[T]) bool) SectionBindingOption[T]
func WithSectionApply[T any](apply func(SectionChange[T]) error) SectionBindingOption[T]

Adapters for short-lived one-shot commands may still call UnmarshalSection directly, but service constructors, clients, transports, health checks, and any component that survives config reload must use ObserveSection or a package specific wrapper built on top of it.

Reload-aware extracted packages should define their own tiny settings-source interfaces when they need to read the latest snapshot directly:

type SettingsSource interface {
    Current() *Settings
}

*config.ObservedSection[package.Settings] can satisfy that interface without the extracted package importing pkg/config.

6.4 Existence policy

SectionExists(key) should answer "is there meaningful configuration for this section?" without requiring callers to inspect Viper directly.

Recommended semantics:

  • true when IsSet(key) is true,
  • true when Sub(key) returns a non-nil structural subtree,
  • true when any known nested key under key is set by file/env/flag/default,
  • false when no value, no subtree, and no nested key exists.

Implementation should be careful with Viper defaults. A default-only section may need to count as existing for runtime option materialisation, but setup "is configured" checks may need file/env/keychain presence only. If both semantics are required, expose two methods:

SectionExists(key string) bool       // resolved view, includes defaults/env
SectionInConfig(key string) bool     // persisted file/config-source view

Open question: whether both should ship in the first implementation.

6.5 Unmarshal source

UnmarshalKey must preserve GTB's env-aware resolution. Viper's native Sub can drop AutomaticEnv behaviour; GTB's Sub deliberately works around this for typed getters. The unmarshal implementation must not regress that behaviour.

Implementation options:

  1. Use Viper's UnmarshalKey against the root resolver after ensuring env bindings/defaults are visible.
  2. Build a map by walking the target struct fields and resolving values via typed getters.
  3. Use AllSettings only as a base and overlay env/flag values for fields.

This is the highest-risk implementation detail. The tests must prove env-prefix values override file values during section unmarshal.

6.6 Tags

Package config structs should use mapstructure tags as the primary decode tags, with json/yaml tags where useful for docs and examples:

type TLSConfig struct {
    Enabled bool   `mapstructure:"enabled" json:"enabled" yaml:"enabled"`
    Cert    string `mapstructure:"cert" json:"cert" yaml:"cert"`
    Key     string `mapstructure:"key" json:"key" yaml:"key"`
}

Adapters should avoid exposing Viper-specific tags in public documentation; they are an implementation detail of GTB's adapter.

6.7 Defaults and validation

Each extracted module should expose either:

func DefaultConfig() Config
func (c Config) Validate() error

or constructor behaviour that clearly documents defaults and validation errors.

GTB adapters should generally:

  1. start from module defaults,
  2. unmarshal the GTB section,
  3. overlay explicit runtime options,
  4. resolve credentials/secrets,
  5. call module validation,
  6. construct the module object.

7. Adapter Pattern

7.1 Package adapter shape

For each package that needs GTB configuration, create an adapter in GTB, close to the framework integration point. Prefer unexported helpers unless downstream GTB applications need to reuse them.

Example:

func openAIConfigFromGTB(cfg config.Containable) (chat.OpenAIConfig, bool, error) {
    section, err := config.UnmarshalSection[chat.OpenAIConfig](cfg, "openai")
    if err != nil {
        return chat.OpenAIConfig{}, false, err
    }

    out := chat.DefaultOpenAIConfig()
    if section.Exists {
        out = out.Merge(section.Value)
    }

    return out, section.Exists, out.Validate()
}

7.2 Constructor shape in extracted modules

Constructors should accept typed config plus functional options for dependencies and runtime objects:

func New(cfg Config, opts ...Option) (*Client, error)

func WithLogger(log *slog.Logger) Option
func WithHTTPClient(client *http.Client) Option
func WithCredentialResolver(resolver CredentialResolver) Option

Configuration values are data. Dependencies are options.

7.3 Hot reload

Hot reload should remain a GTB concern. Extracted modules should not import GTB's observer system.

GTB adapters for long-lived components must use config observers to rehydrate typed settings whenever a reload succeeds. This is an adapter contract, not an optional enhancement. The preferred implementation is config.ObserveSection, or a package-specific helper that wraps ObserveSection with package defaults, validation, and apply behaviour.

The adapter is responsible for unmarshalling a fresh settings snapshot from the updated config.Containable, validating it, and then propagating it to the runtime component. Direct one-shot calls to config.UnmarshalSection are only appropriate for short-lived command paths, tests, or the implementation of the observer-backed helper itself.

For components that support live reconfiguration, adapters should propagate the latest settings through pointers rather than copying values into long-lived closures. The preferred pattern is either:

  • a package-owned settings-source interface, such as Current() *Settings, backed by config.ObserveSection, where the package documents its concurrency guarantees, or
  • a GTB-owned pointer/holder that the adapter updates on reload before invoking a package-specific Reconfigure method.

Avoid mutating shared structs in place without synchronization. If the settings may be read concurrently by request handlers, health checks, transports, or background goroutines, use a mutex, atomic.Pointer[T], or an equivalent package-owned concurrency boundary so readers always observe a consistent settings snapshot.

binding, err := config.ObserveSection[http.Settings](
    cfg,
    "server.http",
    config.WithSectionDefaults(http.DefaultSettings(), http.MergeSettings),
    config.WithSectionValidator(func(settings http.Settings) error {
        return settings.Validate()
    }),
    config.WithSectionApply(func(change config.SectionChange[http.Settings]) error {
        return server.Reconfigure(&change.Current.Value)
    }),
)
if err != nil {
    return err
}

server.SetSettingsSource(binding)

Only packages that genuinely support live reconfiguration should expose reload methods. Otherwise GTB should document that changes require restart and the adapter should still use ObserveSection to keep a rehydrated snapshot available for newly constructed components, but must not pretend that an already-running component applies those changes live.

8. Package-by-Package Boundary Plan

This section covers the packages scored above 5 in the extraction report.

pkg/chat

Current config coupling:

  • Reads provider selection from ai.provider.
  • Reads fallback settings from ai.fallback.*.
  • Reads provider-specific API keys, env refs, keychain refs, model, base URL, and other provider settings.
  • Uses pkg/config directly in OpenAI credential resolution and elsewhere.

Extracted module shape:

type Config struct {
    Provider Provider `mapstructure:"provider"`
    Model    string   `mapstructure:"model"`
}

type Settings struct {
    Config Config
    Logger logger.Logger
}

type FallbackConfig struct {
    Enabled   bool       `mapstructure:"enabled"`
    Providers []Provider `mapstructure:"providers"`
}

type OpenAIConfig struct {
    APIKey  string `mapstructure:"api_key"`
    BaseURL string `mapstructure:"base_url"`
    Model   string `mapstructure:"model"`
}

GTB adapter responsibilities:

  • Unmarshal ai, ai.fallback, openai, anthropic, and gemini sections.
  • Preserve existing GTB config keys and credential cascade.
  • Convert env/keychain/literal credential references into a CredentialResolver.
  • Inject GTB's hardened HTTP client if desired.
  • Keep New, NewWithFallbackSettings, and NewFallbackFromSettings on package-owned settings; keep GTB config and props integration in SettingsFromProps, NewFromProps, NewWithFallback, and NewWithFallbackFromProps.
  • Pass the logger through Settings while the slog-first logger work continues.

Existence checks:

  • ai.provider absence means use package or GTB default provider.
  • Provider section absence means use provider defaults plus env fallback.
  • Credential presence checks remain in GTB because they know GTB's credential storage schema.

pkg/controls

Current config coupling: none.

Extracted module shape:

  • No config struct required for the core controller unless restart/health policy defaults become configurable later.
  • If needed, define ControllerConfig with restart policy, shutdown timeout, signal handling, and health intervals.

GTB adapter responsibilities:

  • If GTB introduces config-driven controls defaults, unmarshal a controls.Config from a controls section and pass it to the controller.
  • Keep service registration and transport health endpoint wiring outside the extracted core.

Existence checks:

  • Not needed for first extraction wave.

pkg/redact

Current config coupling: none.

Extracted module shape:

  • Existing functions can remain config-free.
  • Optional future Config may include custom patterns or disabled defaults.

GTB adapter responsibilities:

  • If GTB allows custom redaction rules in config, unmarshal them in telemetry or doctor adapters and pass explicit rule sets to redact.

Existence checks:

  • Not needed unless custom user rules are introduced.

pkg/changelog

Current config coupling: none.

Extracted module shape:

  • Existing generation options should remain explicit structs/options.
  • If CLI defaults are config-driven, define GenerateConfig in the changelog module rather than taking config.Containable.

GTB adapter responsibilities:

  • CLI command maps flags/config into changelog.GenerateConfig.

Existence checks:

  • Not needed beyond command-level flag/config precedence.

pkg/authn

Current config coupling: none.

Extracted module shape:

type APIKeyConfig struct {
    Header string   `mapstructure:"header"`
    Keys   []string `mapstructure:"keys"`
}

type JWTConfig struct {
    Issuer   string   `mapstructure:"issuer"`
    Audience []string `mapstructure:"audience"`
    JWKSURL  string   `mapstructure:"jwks_url"`
}

type MTLSConfig struct {
    AllowedSubjects []string `mapstructure:"allowed_subjects"`
}

GTB adapter responsibilities:

  • Transports unmarshal auth sections and construct middleware/verifiers.
  • Credential references should be resolved by GTB before values reach authn.

Existence checks:

  • Section absence means auth mode disabled unless explicitly required by server options.
  • Present-but-invalid auth config is a startup error.

pkg/credentials, keychain, credtest

Current config coupling: none in the package. GTB setup/config migration code uses config heavily.

Extracted module shape:

type Reference struct {
    Mode     Mode   `mapstructure:"mode"`
    Env      string `mapstructure:"env"`
    Keychain string `mapstructure:"keychain"`
    Value    string `mapstructure:"value"`
}

GTB adapter responsibilities:

  • Continue to own migration from literal config to env/keychain references.
  • Unmarshal credential references from provider sections and pass credentials.Reference or a CredentialResolver to extracted modules.

Existence checks:

  • Setup IsConfigured checks need source-aware existence, not just resolved defaults. This is a prime use case for SectionInConfig or explicit Has/IsSet checks in GTB adapters.

pkg/regexutil

Current config coupling: none.

Extracted module shape:

  • Keep config-free.
  • Optional future config may include max pattern length or compile timeout.

GTB adapter responsibilities:

  • If configurable limits are introduced, unmarshal them where user patterns are accepted and pass explicit options to regexutil.

Existence checks:

  • Not needed.

pkg/tls

Current config coupling:

  • Imports pkg/config to resolve TLS settings.

Extracted module shape:

type Pair struct {
    Enabled bool   `mapstructure:"enabled" yaml:"enabled" json:"enabled"`
    Cert    string `mapstructure:"cert"    yaml:"cert"    json:"cert"`
    Key     string `mapstructure:"key"     yaml:"key"     json:"key"`
}

type PairOverrides struct {
    Enabled bool
    Cert    bool
    Key     bool
}

func ResolvePair(shared Pair, transport Pair, overrides PairOverrides) Pair
func (p Pair) ServerConfig(nextProtos ...string) (*tls.Config, error)
func ClientConfig(caFiles ...string) (*tls.Config, error)

GTB adapter responsibilities:

  • Unmarshal server.tls, server.http.tls, server.grpc.tls, or custom prefix sections into typed Pair values.
  • Preserve fallback behaviour between shared and transport-specific TLS config.
  • Resolve paths relative to GTB config/project conventions if needed.

Existence checks:

  • Section absence means TLS disabled unless Enabled is true through another source.
  • Present enabled: true without required cert/key is a validation error.

pkg/vcs/release, releasetest

Current config coupling:

  • Provider registry factory accepts config.Containable.
  • Providers read host overrides, token refs, direct provider templates, filename patterns, and provider-specific parameters.

Extracted module shape:

type SourceConfig struct {
    Type   string            `mapstructure:"type"`
    Owner  string            `mapstructure:"owner"`
    Repo   string            `mapstructure:"repo"`
    Host   string            `mapstructure:"host"`
    Params map[string]string `mapstructure:"params"`
}

type ProviderConfig struct {
    Host  string            `mapstructure:"host"`
    Token credentials.Reference `mapstructure:"token"`
    Params map[string]string `mapstructure:"params"`
}

type ProviderFactory func(SourceConfig, ProviderConfig, ...Option) (Provider, error)

GTB adapter responsibilities:

  • Convert props.Tool.ReleaseSource plus runtime config overrides into release.SourceConfig.
  • Unmarshal provider-specific sections (github, gitlab, bitbucket, gitea, direct) into provider config structs.
  • Resolve credential refs through GTB's credential backend before constructing providers, or pass a credential resolver option.

Existence checks:

  • Runtime vcs.provider override remains a GTB adapter concern.
  • Provider section absence should not prevent public unauthenticated release lookups when a provider supports them.

pkg/vcs/repo and pkg/vcs/repo/aferobilly

Current config coupling:

  • repo imports props and reads props.Config for provider override, SSH key settings, and auth token resolution.
  • aferobilly has no config coupling.

Extracted module shape:

type Settings struct {
    ReleaseSource release.ReleaseSourceConfig
    Forge         string
    AuthEnabled   bool
    Auth          vcs.TokenConfig
    SSH           SSHSettings
    Logger        diagnosticLogger
    FS            afero.Fs
}

type SSHSettings struct {
    Configured bool
    HasKey     bool
    Type       string
    Env        string
    Path       string
}

GTB adapter responsibilities:

  • Map props.Tool.ReleaseSource, vcs.provider, github.ssh, gitlab.ssh, etc. into repo.Settings.
  • Preserve token resolution through the narrow vcs.TokenConfig surface so env, keychain, literal, and fallback environment behaviour remains unchanged.
  • Keep NewRepoFromProps and SettingsFromContainable as GTB compatibility adapters while NewRepo consumes typed settings.

Existence checks:

  • SSH section absence means use HTTPS/token or provider defaults.
  • type: agent should not require a path.

pkg/browser

Current config coupling: none.

Extracted module shape:

type Config struct {
    AllowedSchemes []string `mapstructure:"allowed_schemes"`
    MaxURLLength   int      `mapstructure:"max_url_length"`
}

GTB adapter responsibilities:

  • Only needed if GTB allows tool authors to override browser policy.
  • Defaults should remain secure without config.

Existence checks:

  • Not needed for current behaviour.

pkg/forms

Current config coupling: none.

Extracted module shape:

  • Keep config-free.
  • If defaults are needed later, use explicit ThemeConfig or InteractionConfig structs.

GTB adapter responsibilities:

  • Setup commands choose interactive/non-interactive behaviour from GTB command flags/config and pass explicit options to forms.

Existence checks:

  • Not needed in forms itself.

pkg/logger

Current config coupling: none.

Extracted module shape:

type Config struct {
    Level     string `mapstructure:"level"`
    Format    string `mapstructure:"format"`
    Timestamp bool   `mapstructure:"timestamp"`
    Caller    bool   `mapstructure:"caller"`
}

GTB adapter responsibilities:

  • Root command unmarshals log into logger config.
  • Construct Charm slog.Handler and shared slog.LevelVar.
  • Preserve --debug precedence over config.

Existence checks:

  • Absence means default human-friendly logger.
  • Invalid level/format is a startup config error or warning according to current behaviour.

pkg/output

Current config coupling: none.

Extracted module shape:

type Config struct {
    Format  string `mapstructure:"format"`
    NoStyle bool   `mapstructure:"no_style"`
}

GTB adapter responsibilities:

  • Commands map --output, --no-style, and config defaults into output renderer options.
  • Cobra writers remain the output destination.

Existence checks:

  • Output flags usually override config; section absence means command defaults.

pkg/workspace

Current config coupling: none.

Extracted module shape:

type Config struct {
    Markers []string `mapstructure:"markers"`
}

GTB adapter responsibilities:

  • Generator/internal commands may pass marker overrides explicitly if GTB ever makes them configurable.

Existence checks:

  • Not needed for current behaviour.

pkg/http

Current config coupling:

  • Adapters read server port, max header bytes, rate limit, circuit breaker, and TLS paths from config.Containable.
  • Core server construction now accepts typed ServerSettings; compatibility adapters keep existing GTB config call sites working.

Extracted module shape:

type ServerSettings struct {
    Port           int `mapstructure:"port" yaml:"port" json:"port"`
    MaxHeaderBytes int `mapstructure:"max_header_bytes" yaml:"max_header_bytes" json:"max_header_bytes"`
}

type ServerSettingsSource interface {
    Current() *ServerSettings
    Version() uint64
}

type ClientConfig struct {
    Timeout        time.Duration `mapstructure:"timeout"`
    Retry          RetryConfig   `mapstructure:"retry"`
    TLS            tls.Config    `mapstructure:"tls"`
    CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"`
}

GTB adapter responsibilities:

  • Unmarshal server.http into http.ServerSettings.
  • Bind long-lived HTTP composition with ObserveServerSettingsFromConfig, which wraps config.ObserveSection and exposes Current() / Version().
  • Preserve fallback from server.http.* to shared server.* where currently supported. The first implemented fallback is server.port to server.http.port, rehydrated on config reload via dynamic defaults.
  • Resolve TLS through typed TLS config structs.
  • Keep controller registration and health endpoint wiring in GTB/transport integration code.

Existence checks:

  • Section absence means use built-in server defaults.
  • Explicit port: 0 should remain distinguishable from absent port if current behaviour allows ephemeral ports.
  • Existing servers do not auto-restart when an observed port changes; the observed source is for newly constructed servers or explicit package-level reconfiguration logic.

pkg/grpc

Current config coupling:

  • Adapters read port, reflection, TLS, rate limit, circuit breaker, and dial options from config.Containable.
  • Core server construction, start, dial, and registration now accept typed ServerSettings and explicit TLS pairs; compatibility adapters keep existing GTB config call sites working.

Extracted module shape:

type ServerSettings struct {
    Port       int  `mapstructure:"port" yaml:"port" json:"port"`
    Reflection bool `mapstructure:"reflection" yaml:"reflection" json:"reflection"`
}

type ServerSettingsSource interface {
    Current() *ServerSettings
    Version() uint64
}

type ClientConfig struct {
    Target string     `mapstructure:"target"`
    TLS    tls.Config `mapstructure:"tls"`
}

GTB adapter responsibilities:

  • Unmarshal server.grpc into grpc.ServerSettings.
  • Bind long-lived gRPC composition with ObserveServerSettingsFromConfig, which wraps config.ObserveSection and exposes Current() / Version().
  • Preserve fallback to shared server config where currently supported. The first implemented fallback is server.port to server.grpc.port, rehydrated on config reload via dynamic defaults.
  • Build grpc.ServerOption / dial options from typed config.
  • Resolve TLS through typed TLS config structs.

Existence checks:

  • Reflection default must preserve existing generated-tool behaviour.
  • TLS/auth sections should be optional unless enabled.
  • Existing servers do not auto-restart when an observed port changes; the observed source is for newly constructed servers or explicit package-level reconfiguration logic.

pkg/gateway

Current config coupling:

  • Adapters compose HTTP/gRPC settings through transport packages.
  • Core gateway construction accepts a prepared gRPC client connection and typed HTTP server settings; compatibility adapters keep existing GTB config call sites working.

Extracted module shape:

type Settings struct {
    HTTP    http.ServerSettings `mapstructure:"http" yaml:"http" json:"http"`
    HTTPTLS tls.Pair            `mapstructure:"http_tls" yaml:"http_tls" json:"http_tls"`
    GRPC    grpc.ServerSettings `mapstructure:"grpc" yaml:"grpc" json:"grpc"`
    GRPCTLS tls.Pair            `mapstructure:"grpc_tls" yaml:"grpc_tls" json:"grpc_tls"`
}

type SettingsSource interface {
    Current() *Settings
    Version() uint64
}

GTB adapter responsibilities:

  • Compose already-typed HTTP and gRPC settings with SettingsFromConfig.
  • Bind long-lived gateway composition with ObserveSettingsFromConfig, which wraps config.ObserveSection and exposes Current() / Version().
  • Register generated gateway handlers.

Existence checks:

  • Gateway absence means not registered.
  • Existing gateway servers and gRPC client connections do not auto-restart or redial when observed settings change; the observed source is for newly constructed gateways or explicit package-level reconfiguration logic.

pkg/openapi

Current config coupling: none directly; depends on HTTP.

Extracted module shape:

type Config struct {
    SpecPath string `mapstructure:"spec_path"`
    Mount    string `mapstructure:"mount"`
    UI       bool   `mapstructure:"ui"`
}

GTB adapter responsibilities:

  • Mount OpenAPI handlers on typed HTTP server config.

Existence checks:

  • Absence means no OpenAPI route unless explicitly registered.

pkg/telemetry/otelcore, logs, metrics, tracing

Current config coupling:

  • otelcore reads telemetry.* endpoint, headers, insecure flags, and signal-specific overrides from config.Containable.

Extracted module shape:

type OTelConfig struct {
    Endpoint string            `mapstructure:"endpoint"`
    Insecure bool              `mapstructure:"insecure"`
    Headers  map[string]string `mapstructure:"headers"`
    Traces   SignalConfig      `mapstructure:"traces"`
    Metrics  SignalConfig      `mapstructure:"metrics"`
    Logs     SignalConfig      `mapstructure:"logs"`
}

type SettingsSource interface {
    Current() *Settings
    Version() uint64
}

GTB adapter responsibilities:

  • Resolve shared telemetry.* config plus telemetry.<signal>.* overrides into a package-owned Settings value.
  • Bind long-lived signal setup with ObserveSettingsFromConfig so reloads rehydrate the full resolved signal snapshot and expose Version() for change detection.
  • Apply GTB service name/version/resource attributes from props.Tool and version metadata.

Existence checks:

  • Section absence means observability disabled unless enabled by env or code.
  • Signal-specific sections override root OTel config.
  • Existing exporters do not rebuild automatically on observer changes; tracing, metrics, and logs packages need explicit rebuild/restart logic if live reload should affect already constructed providers.

pkg/telemetry

Current config coupling:

  • Root telemetry reads product analytics enablement, local-only mode, delivery mode, backend config, data dir, deletion request settings, and tool metadata through props and config.

Extracted module shape:

type Config struct {
    Enabled     bool   `mapstructure:"enabled"`
    LocalOnly   bool   `mapstructure:"local_only"`
    Backend     string `mapstructure:"backend"`
    Endpoint    string `mapstructure:"endpoint"`
    Delivery    string `mapstructure:"delivery"`
    DataDir     string `mapstructure:"data_dir"`
}

GTB adapter responsibilities:

  • Map telemetry config, org policy, tool metadata, version, machine ID, and data directory into telemetry config/options.
  • Keep operational observability provider setup on package-owned ObservabilitySettings, with ObservabilitySettingsFromProps and SetupFromProps as GTB adapters for existing telemetry.* keys.
  • Keep telemetry data-directory resolution on a package-owned config-file path, with ResolveDataDirFromProps as the GTB adapter.
  • Keep consent prompts and config persistence in GTB command/setup layers.

Existence checks:

  • Must distinguish absent consent from explicit disabled/enabled.
  • This likely needs source-aware checks rather than default-only resolution.

pkg/telemetry/posthog, pkg/telemetry/datadog

Current config coupling:

  • Backend adapters do not use config directly but are constructed by root telemetry code using config-derived values.

Extracted module shape:

type PostHogConfig struct {
    ProjectKey string `mapstructure:"project_key"`
    Endpoint   string `mapstructure:"endpoint"`
}

type DatadogConfig struct {
    APIKey   string `mapstructure:"api_key"`
    Endpoint string `mapstructure:"endpoint"`
}

GTB adapter responsibilities:

  • Unmarshal backend-specific sections and resolve secret refs.
  • Pass standard *http.Client or backend options.

Existence checks:

  • Backend section required only when selected backend is active.

pkg/vcs

Current config coupling:

  • Token resolution reads env refs, literal values, and keychain refs from config.Containable.

Extracted module shape:

type AuthConfig struct {
    Env      string `mapstructure:"env"`
    Value    string `mapstructure:"value"`
    Keychain string `mapstructure:"keychain"`
}

GTB adapter responsibilities:

  • Convert provider auth sections into auth config or credential references.
  • Resolve keychain values with GTB credential backend.

Existence checks:

  • Env ref presence can be configured even if env var is missing; adapter decides whether that is a warning or error in context.

pkg/vcs providers

Current config coupling:

  • Providers read host/API URL overrides, token refs, filename patterns, direct download templates, and provider-specific params.

Extracted module shape:

type GitHubSettings struct {
    ReleaseSource release.ReleaseSourceConfig
    APIURL        string         `mapstructure:"url.api"`
    UploadURL     string         `mapstructure:"url.upload"`
    Auth          vcs.AuthConfig `mapstructure:"auth"`
}

type GitLabSettings struct {
    ReleaseSource release.ReleaseSourceConfig
    APIURL        string         `mapstructure:"url.api"`
    Auth          vcs.AuthConfig `mapstructure:"auth"`
}

type GiteaSettings struct {
    ReleaseSource    release.ReleaseSourceConfig
    APIURL           string         `mapstructure:"url.api"`
    Auth             vcs.AuthConfig `mapstructure:"auth"`
    TokenFallbackEnv string
}

type BitbucketSettings struct {
    ReleaseSource  release.ReleaseSourceConfig
    Username        string `mapstructure:"username"`
    AppPassword     string `mapstructure:"app_password"`
    FilenamePattern string `mapstructure:"filename_pattern"`
}

type DirectSettings struct {
    ReleaseSource release.ReleaseSourceConfig
    Token         string `mapstructure:"token"`
}

GTB adapter responsibilities:

  • Preserve current provider-specific config keys.
  • Resolve credentials.
  • Pass standard HTTP clients or extracted transport clients.

Existence checks:

  • Provider configs are optional unless the selected provider requires a field.

pkg/errorhandling

Current config coupling: none directly; it depends on logger and help config from props.Tool.

Extracted module shape:

type HelpConfig struct {
    Slack string `mapstructure:"slack"`
    Teams string `mapstructure:"teams"`
}

GTB adapter responsibilities:

  • Continue to map props.Tool.Help into error handler construction.
  • If help channels become runtime-configurable, unmarshal them in root/setup.

Existence checks:

  • Absence means no help footer.

9. Config Package Work

Phase 1: Add typed unmarshal support

  • Add Unmarshal, UnmarshalKey, and SectionExists to Containable.
  • Implement those methods on Container and sub-containers.
  • Add generic Section[T] and UnmarshalSection[T].
  • Add ObservedSection[T], ObserveSection[T], and binding options for defaults, validation, and apply callbacks.
  • Decide whether SectionInConfig is needed in phase 1.

Phase 2: Prove env-aware unmarshalling

  • Add tests where file config provides one value and prefixed env vars override nested fields during UnmarshalKey.
  • Add tests for sub-container unmarshalling retaining env binding.
  • Add tests for absent section, present empty section, present invalid section, and default-only section behaviour.
  • Add tests proving ObserveSection performs the initial unmarshal, registers an observer, rehydrates fresh typed settings after reload, preserves the prior valid snapshot when validation fails, and invokes apply callbacks only after a successful rehydrate.

Phase 3: Introduce package config structs

  • Add typed config structs to first-wave packages that currently read config.Containable directly: chat, tls, http, grpc, vcs/release, vcs providers, telemetry/otelcore.
  • Keep constructors additive and backwards compatible where public APIs already exist.
  • Update the component documentation for each package in the same package commit, showing the new typed settings structs, default behaviour, validation rules, and adapter boundary.

Phase 4: Move GTB config reads into adapters

  • Replace direct package-internal cfg.GetString / cfg.GetBool reads with adapter code that unmarshals typed structs.
  • Use ObserveSection in adapters for long-lived services, clients, transports, health checks, and reload-aware runtime components.
  • Keep existing command/setup config keys.
  • Add migration notes only if any public constructor names or config semantics change.
  • Update concept and how-to documentation in the same package commit when the user-facing construction or configuration story changes.

Phase 5: Documentation consolidation

  • Add or update concept docs explaining config as a framework adapter boundary, including why extracted modules own typed settings instead of importing pkg/config.
  • Add or update component docs for pkg/config to cover UnmarshalSection, ObserveSection, existence checks, defaults, validation, and reload semantics.
  • Update generator documentation and generated-project docs whenever scaffolded code changes to use typed settings or config adapters.
  • Add migration notes under docs/migration/ for every public API change, including pre-1.0 intentional breaks, so the eventual v1 migration guide has a complete audit trail.
  • Update the active extraction specs to reference the typed settings and observer-backed adapter pattern instead of config.Containable.
  • Cross-check examples against compiling code before marking a package phase complete.

Phase 6: Extraction readiness checks

  • Add import-boundary tests or CI checks for packages that should no longer import pkg/config.
  • Update extraction specs to reference typed config structs rather than ConfigLookup as the primary seam.
  • Treat missing or stale docs as an extraction-readiness failure even when code and tests pass.

10. TDD / BDD Strategy

Implementation must be test-first.

TDD requirements:

  • Write config package tests for UnmarshalKey, UnmarshalSection, existence semantics, env-prefix precedence, sub-container behaviour, observer-backed rehydration, invalid-reload preservation, apply callbacks, and error handling before implementation.
  • For each migrated package, write tests for typed config defaults, validation, and GTB adapter mapping before replacing direct config reads.
  • For compatibility wrappers, write tests that prove existing constructors still behave as before.
  • Add import-boundary tests before removing pkg/config imports from extracted candidates.

BDD requirements:

  • Config section unmarshalling itself is library behaviour and should be covered by unit/integration tests, not broad BDD.
  • Add or update BDD scenarios when a user-visible CLI workflow changes, such as init, config migrate-credentials, serve docs, generated server startup, or provider setup.
  • Generator-affecting config changes need generator E2E coverage: scaffold a project, build it, and confirm generated config-backed components still start.
  • If no BDD scenario is added for a phase, implementation notes must state why unit/integration coverage is sufficient.

11. Documentation

Documentation is part of the implementation contract for this refactor. A package migration is not complete until its documentation has been updated, reviewed against the code, and included in the same package commit or a clearly paired documentation commit.

Required documentation updates:

  • docs/explanation/components/config/index.md
  • docs/explanation/components/config/sources-and-precedence.md
  • docs/explanation/components/config/validation.md
  • docs/components/<package>.md or the package's existing component page for every migrated package.
  • The package-level doc.go file for every migrated package, so pkg.go.dev describes the typed settings boundary, GTB adapter helpers, and reload behaviour accurately.
  • docs/concepts/ pages that describe framework composition, dependency injection, service lifecycle, credentials, telemetry, or provider setup when those concepts change.
  • docs/how-to/ guides when downstream users need to change construction, configuration, testing, or generated-code patterns.
  • docs/migration/ notes for every public constructor/signature/config semantic change, including intentional pre-1.0 breaks.
  • Generator templates and generated-project docs when scaffolded projects need to use typed settings or *FromContainable adapters.
  • docs/development/dependency-management.md
  • docs/development/specs/2026-07-05-chat-module-extraction.md
  • docs/development/specs/2026-07-07-slog-first-extraction-seams.md

Documentation must explain:

  • config loading remains a GTB framework concern,
  • extracted modules define typed config structs,
  • GTB adapters unmarshal resolved config sections into those structs,
  • long-lived GTB adapters use observer-backed rehydration through ObserveSection or package-specific wrappers,
  • Exists controls optional sections and setup/configured checks,
  • non-GTB consumers can populate structs with any config system,
  • which config keys remain unchanged for GTB users,
  • which constructors are the long-term typed settings API,
  • which *FromContainable helpers are framework adapters,
  • how reload behaves for packages that apply changes live versus packages that require restart,
  • how tests should stay on typed settings and keep config-container coverage in adapter tests.

Per-package documentation checklist:

  • Document the package-owned settings structs and defaults.
  • Document validation errors and required fields.
  • Document the GTB adapter function names and the config sections they read.
  • Document whether the package supports live reconfiguration, restart-only reconfiguration, or no reload semantics.
  • Update package-level doc.go comments so generated API documentation matches the component docs.
  • Include at least one config-backed GTB example and one config-free typed settings example where the package is likely to be consumed outside GTB.
  • Update examples after generator changes by scaffolding a project and checking generated code against the docs.

Documentation review requirements:

  • Every package commit must state which docs were updated or why no docs changed.
  • Examples must use current exported names and compile where practical.
  • Stale references to config.Containable as the primary package boundary must be removed or explicitly marked as GTB adapter compatibility.
  • The extraction report and active extraction specs must stay aligned with the implemented package boundaries.
  • Missing documentation blocks progression to the next extraction-candidate package unless the gap is explicitly recorded as deferred in this spec or a follow-up issue.

12. Open Questions

Resolved during the first-wave implementation review (2026-07-10).

  1. SectionExists vs SectionInConfig. Resolved: ship SectionExists only. The resolved-view semantics (file/env/flag/default) cover every first-wave adapter, and the one source-aware "is configured" case (telemetry consent) is served by direct IsSet/Has checks in its adapter. SectionInConfig is deferred until a package genuinely needs persisted-source-only presence; it can be added without breaking callers.
  2. UnmarshalKey on Containable vs helpers. Resolved: added directly to Containable (Unmarshal, UnmarshalKey, SectionExists), with the generic UnmarshalSection[T]/ObserveSection[T] helpers layered on top. The interface widening is justified because these are the canonical section-decode primitives every adapter builds on.
  3. Tag strategy. Resolved: mapstructure primary, plus json/yaml for documentation and serialisation. Structs that are not decoded via UnmarshalSection (see §7 note below) must not carry mapstructure tags that imply a decode contract they do not honour — dotted tags such as url.api are a footgun and were removed.
  4. Adapter location. Resolved: inside each package, in a clearly named config_adapter.go (and *FromProps/*FromContainable helpers). This keeps the GTB coupling co-located with the package it adapts and trivially separable at extraction time, and is now enforced by a source-level guard test (test/architecture) that keeps pkg/props out of package cores.
  5. pkg/config extraction name. Still open, deferred to the pkg/config extraction spec itself. Not a prerequisite for this work.

Note on decode mechanism (recorded from the review)

Not every adapter uses UnmarshalSection, and that is intentional. Per §5.2, lookup-style resolution remains correct for:

  • provider registries with dynamic, provider-specific keys — the pkg/vcs providers decouple through a narrow local release.Config/vcs.TokenConfig interface (which imports no GTB packages at all, the cleanest possible seam) rather than typed decode;
  • override-precedence resolutionpkg/tls and telemetry/otelcore resolve shared-plus-transport/signal settings using per-field IsSet override detection, which a single typed decode cannot express;
  • multi-prefix compositionpkg/gateway composes settings that live at different config prefixes, so its Settings is assembled by the adapter, not decoded from one section.

UnmarshalSection/ObserveSection are the default for self-contained sections (chat, http, grpc server settings). The structs used by the lookup-based adapters carry doc-only json/yaml tags and no mapstructure decode tags.

First-wave status (2026-07-10)

Landed in this MR: the config API additions (Phases 1–2), first-wave package adapters (chat, tls, http, grpc, gateway, telemetry/otelcore, vcs + providers + repo) with props coupling confined to adapter files, the supporting docs (Phase 5), and the props import-boundary guard (part of Phase 6). vcs/release received a narrowed registry factory signature rather than a full typed adapter, since its providers own the typed settings.

Deferred to later waves: the remaining §8 packages that currently need no config or belong to a later extraction wave (authn, credentials, output, browser, workspace, forms, changelog, redact, openapi, errorhandling, and the telemetry backends).

Completion (2026-07-12)

This spec is marked IMPLEMENTED. The pattern it set out to establish — typed, package-owned config structs as the primary boundary, with GTB unmarshalling resolved sections through co-located adapters — is fully delivered and in use by every first-wave package that reads config. The container APIs (Unmarshal, UnmarshalKey, SectionExists, UnmarshalSection[T], ObserveSection[T]), the test/architecture import-boundary guard, and all §12 open questions are resolved.

The "deferred" §8 packages do not represent pending work under this spec:

  • No-config packages (authn, credentials, output, browser, workspace, forms, changelog, redact, regexutil, openapi, logger, controls) have Current config coupling: none. There is nothing to migrate. Their §8 entries are forward-looking guidance for if and when each package is extracted, at which point the typed-struct + *FromContainable adapter pattern defined here applies directly. errorhandling couples to config only indirectly through help config and logger, and follows the same rule at its own extraction time.
  • The telemetry backends and root pkg/telemetry are not blocked on a config adapter — root telemetry's config reads are already adapter-confined and its pkg/props type coupling was severed via 2026-07-10-telemetry-props-decoupling.md. What remains is the larger product-analytics ↔ observability split, which is an architectural effort rather than a config-boundary task and is tracked in its own spec: 2026-07-12-telemetry-analytics-observability-split.md.

Follow-up landed on this branch (2026-07-11)

  • Phase 6 automated import-boundary check — done. The guard in test/architecture/boundary_test.go is now table-driven over pkg/props and pkg/logger across every first-wave core plus pkg/telemetry. It runs in CI via the existing go test ./... MR component; no separate job is required.
  • Root pkg/telemetry props-type coupling — done. Its config reads were already adapter-confined; the last coupling was to props.EventType / props.DeliveryMode, resolved by relocating those types to the dependency-free pkg/telemetrytypes leaf under 2026-07-10-telemetry-props-decoupling.md (IMPLEMENTED). pkg/props retains the names as aliases, so downstream tools are unaffected. The larger product-analytics ↔ observability split needed for full telemetry extraction remains a separate, later effort.

Joint delivery with the slog-first spec (2026-07-10)

The 2026-07-07-slog-first-extraction-seams.md spec was approved on 2026-07-10 and is delivered on this same branch so the logging and config seams land as one cut-over. Consequences for this spec:

  • logger gains its typed Config (this spec, §8) and its logging boundary is redefined to mirror *slog.Logger (slog-first, D1). The two are complementary: logger.Config.CharmOptions() feeds the slog-first NewCharmHandler construction.
  • The slog-first spec's config strategy (its §7) explicitly defers to this spec: typed UnmarshalSection structs are the primary boundary and pkg/config is an acceptable lightweight dependency (D6).

13. Acceptance Criteria

  • pkg/config can unmarshal resolved, env-aware config sections into typed structs.
  • Adapters can distinguish absent sections from present invalid sections.
  • First-wave extraction candidates have package-owned typed config structs or a documented reason they need no config.
  • New extraction specs use typed config structs as the primary config boundary.
  • Extracted modules are not required to import pkg/config.
  • Existing GTB config keys and precedence semantics remain intact.