Files
OpenFlare/docs/superpowers/plans/2026-08-29-cordis-config-extension.md
T
ryan efa75558af autoresearch iter 20: give the telegram poller a real long-poll window
telebot types LongPoller.Timeout as time.Duration and sends
int(timeout / time.Second) to getUpdates, so the literal 10 meant ten
nanoseconds: Telegram received timeout=0, long polling never held the
connection, and the adapter polled the Bot API in a tight loop instead.
Use 10 seconds and extract the settings so the conversion is asserted.

The adapter also has no media temp-dir cleanup (downloadMedia creates an
MkdirTemp per attachment and nothing removes it); that is left as a separate
change rather than bundled here.
2026-08-29 09:32:27 +08:00

86 KiB
Raw Blame History

Cordis 配置扩展点(框架与门禁)Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在微内核中落地"插件声明配置字段、内核按声明解析、门禁决定插件激活"的配置扩展点,并证明其解析结果与现有 backend/pkg/config 逐 key 等价。

Architecture: core/extpoints 实现纯 stdlib 的配置引擎(声明注册表 + 优先级解析 + 冲突校验 + 脱敏导出),viper 装载隔离在 plugins/infra/config 适配器内;App.Prepare() 作为解析屏障,Fiber 新增 FiberSkipped 态承载门禁结果。

Tech Stack: Go 1.25.7、github.com/spf13/viper v1.21.0(仅适配器)、github.com/stretchr/testify(测试)、github.com/google/go-cmp(对拍)、golangci-lint(gofumpt + cyclop/funlen/mnd/revive/dupl/gosec)。

Spec: docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md


本计划范围(对应 spec §7.3 的 P1 + P2)

本计划交付内核能力,不改动任何业务插件与 cmd:完成后旧的全局单例 config.Config 仍然是生产路径的唯一配置来源,应用行为零变化,新增能力由单测与新旧对拍证明。

下一个计划(P3 + P4,本计划完成后另行编写) 才做 27 个消费文件的迁移、pkg/idgen 解耦与 backend/pkg/config 删除。分期理由:迁移的声明结构体写法依赖本计划定稿的 tag 与 API 形状,先写会在实现过程中失真。

文件结构

文件 职责 动作
backend/core/extpoints/config.go 配置引擎的抽象与声明注册表:ConfigSource、ConfigBinding、ConfigEntry、ConfigView、ConfigExtension、ConfigRegistry.Declare + tag 遍历 + 冲突校验 Create
backend/core/extpoints/config_value.go 值解码:convertValue 及其 bool/int/uint/float/string/duration/slice/struct 分支 Create
backend/core/extpoints/config_resolve.go Resolve 优先级链、Bind 赋值、只读访问器、Entries 脱敏导出 Create
backend/core/extpoints/config_test.go 引擎单测(外部测试包 extpoints_test,fake source) Create
backend/core/config.go ConfigGet[T] 泛型读取入口 Create
backend/core/config_test.go 泛型读取与 Context.Config() 接入测试 Create
backend/core/types.go ConfigExtension/ConfigBinding/ConfigEntry/ConfigSource 别名 + ConfigGatedPlugin 可选接口 Modify
backend/core/context.go config 字段、NewContext 初始化、Fork 共享、Config() 访问器 Modify
backend/core/fiber.go FiberSkipped 状态与 Skip() Modify
backend/core/app.go WithConfigSource/WithConfigDecl/Prepare/SetShutdownTimeout、Use 收集声明、reconcileLocked 门禁求值 Modify
backend/plugins/infra/config/source.go viper + yaml 适配器,实现 core.ConfigSource,保留 CONFIG_PATH 与向上查找语义 Create
backend/plugins/infra/config/source_test.go 适配器单测(t.TempDir() + t.Setenv) Create
backend/pkg/config/config.go 抽出可重入 load(configPath string, testMode bool)(仅重构,行为不变) Modify
backend/pkg/config/parity_test.go 新旧解析对拍(临时文件,P4 随旧包删除) Create then Delete in P4
scripts/check_cordis_architecture.sh 微内核禁 viper 检查项 Modify

约束提示: 所有新增导出符号必须带符合 go-documentation 规范的文档注释(revive 会检查);测试统一用 t.TempDir(),禁止相对路径创建临时目录。


Task 1: 配置引擎的抽象与声明注册表

Files:

  • Create: backend/core/extpoints/config.go

  • Test: backend/core/extpoints/config_test.go

  • Step 1: 写失败的测试

创建 backend/core/extpoints/config_test.go:

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0

package extpoints_test

import (
	"Wavelet/core/extpoints"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// fakeSource is an in-memory extpoints.ConfigSource used by configuration engine tests.
type fakeSource struct {
	values map[string]any
	env    map[string]string
}

func newFakeSource() *fakeSource {
	return &fakeSource{values: map[string]any{}, env: map[string]string{}}
}

func (f *fakeSource) Lookup(path string) (any, bool) {
	v, ok := f.values[path]
	return v, ok
}

func (f *fakeSource) LookupEnv(name string) (string, bool) {
	v, ok := f.env[name]
	return v, ok
}

func (f *fakeSource) Describe() string { return "fake" }

// redisConfig mirrors how a plugin declares the configuration it reads.
type redisConfig struct {
	Enabled   bool          `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
	Addrs     []string      `config:"addrs" env:"REDIS_ADDR"`
	DB        int           `config:"db" env:"REDIS_DB"`
	KeyPrefix string        `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
	Dial      time.Duration `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"`
	Ignored   string        `config:"-"`
	private   string
}

func TestDeclareRegistersTaggedLeafKeys(t *testing.T) {
	r := extpoints.NewConfigRegistry(newFakeSource())

	require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))

	keys := make([]string, 0)
	for _, e := range r.Entries() {
		keys = append(keys, e.Key)
	}
	assert.Equal(t, []string{
		"redis.addrs", "redis.db", "redis.dial_timeout", "redis.enabled", "redis.key_prefix",
	}, keys)
}

func TestDeclareRejectsNonStructPointerTarget(t *testing.T) {
	r := extpoints.NewConfigRegistry(newFakeSource())

	assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: redisConfig{}}),
		extpoints.ErrConfigTarget)
	assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: (*redisConfig)(nil)}),
		extpoints.ErrConfigTarget)
}

func TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata(t *testing.T) {
	r := extpoints.NewConfigRegistry(newFakeSource())
	binding := extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}
	require.NoError(t, r.Declare("cache", binding))
	require.NoError(t, r.Declare("cache_memory", binding), "identical shared declarations must be allowed")

	type conflictingConfig struct {
		Enabled bool `config:"enabled" env:"REDIS_ON" default:"true"`
	}
	err := r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "redis", Target: &conflictingConfig{}})
	require.ErrorIs(t, err, extpoints.ErrConfigConflict)
	assert.Contains(t, err.Error(), "redis.enabled")
	assert.Contains(t, err.Error(), "cache")
	assert.Contains(t, err.Error(), "driver_http")
}
  • Step 2: 运行测试确认失败

Run: cd backend && go test ./core/extpoints/ -run 'TestDeclare' -v Expected: 编译失败,报 undefined: extpoints.NewConfigRegistry、undefined: extpoints.ConfigBinding、undefined: extpoints.ErrConfigTarget、undefined: extpoints.ErrConfigConflict。

  • Step 3: 写最小实现

创建 backend/core/extpoints/config.go:

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0

package extpoints

import (
	"errors"
	"fmt"
	"reflect"
	"strings"
	"sync"
	"time"
)

// Sentinel errors returned by the configuration extension point.
var (
	// ErrConfigConflict is returned when the same key is declared with disagreeing metadata.
	ErrConfigConflict = errors.New("extpoints: conflicting configuration declarations")

	// ErrConfigType is returned when a value cannot be converted to the declared type.
	ErrConfigType = errors.New("extpoints: configuration value type mismatch")

	// ErrConfigInvalid is returned when a resolved value violates a declared value range.
	// Reserved for source-level value checks; per-plugin value ranges are validated by the
	// declaring plugin after Bind (see spec §4.3 C1).
	ErrConfigInvalid = errors.New("extpoints: invalid configuration value")

	// ErrConfigUnknownKey is returned when a configuration key was never declared.
	ErrConfigUnknownKey = errors.New("extpoints: unknown configuration key")

	// ErrConfigNotResolved is returned when typed reads happen before resolution.
	ErrConfigNotResolved = errors.New("extpoints: configuration not resolved; run App.Prepare first")

	// ErrConfigTarget is returned when a binding target is not an addressable struct pointer.
	ErrConfigTarget = errors.New("extpoints: configuration binding target must be a non-nil struct pointer")

	// ErrConfigNoSource is returned when resolution is attempted without a registered source.
	ErrConfigNoSource = errors.New("extpoints: no configuration source registered")
)

// Configuration origin labels reported by ConfigView.Origin and ConfigEntry.Origin.
const (
	// OriginEnv marks a value that came from an environment variable.
	OriginEnv = "env"
	// OriginAutoEnable marks a boolean enabled by the presence of another environment variable.
	OriginAutoEnable = "auto-enable"
	// OriginFile marks a value that came from the configuration file.
	OriginFile = "file"
	// OriginDefault marks a value that came from a declaration default.
	OriginDefault = "default"
)

// durationType distinguishes time.Duration from plain int64 during tag walking and decoding.
var durationType = reflect.TypeFor[time.Duration]()

// ConfigSource abstracts where raw configuration values come from, keeping the
// micro-kernel free of concrete loaders such as viper.
type ConfigSource interface {
	// Lookup returns the raw value stored at a dotted path in the configuration file.
	Lookup(path string) (any, bool)
	// LookupEnv returns the raw value of an environment variable.
	LookupEnv(name string) (string, bool)
	// Describe returns a human readable identity for the source, used in diagnostics.
	Describe() string
}

// ConfigBinding declares that a plugin reads every `config` tagged field of Target
// under a dotted configuration prefix.
type ConfigBinding struct {
	// Prefix is the dotted configuration path, e.g. "redis". An empty prefix means
	// each field's `config` tag is already a full path.
	Prefix string
	// Target must be a non-nil pointer to a struct carrying `config` tags.
	Target any
}

// configField is a single leaf discovered while walking a binding struct's tags.
// key is the fully qualified dotted path used for resolution; path is the raw `config`
// tag value used to locate the Go field again during Bind.
type configField struct {
	key        string
	path       string
	env        string
	autoEnable string
	def        string
	secret     bool
	typ        reflect.Type
}

// configDecl is the registered form of a configField, attributed to its declaring plugin.
type configDecl struct {
	key        string
	pluginID   string
	env        string
	autoEnable string
	def        string
	secret     bool
	typ        reflect.Type
}

// ConfigEntry is a redacted, self-describing view of one effective configuration key.
type ConfigEntry struct {
	Key      string
	PluginID string
	Env      string
	Origin   string
	Value    string
}

// ConfigView is the read-only surface over effective configuration values.
// Keys are dotted paths such as "redis.enabled".
type ConfigView interface {
	Value(key string) (any, bool)
	String(key, fallback string) string
	Bool(key string, fallback bool) bool
	Int(key string, fallback int) int
	Duration(key string, fallback time.Duration) time.Duration
	Strings(key string) []string
	WasSet(envName string) bool
	Origin(key string) string
}

// ConfigExtension is the plugin-facing configuration extension point mounted on the
// root Context and shared by every forked plugin scope.
type ConfigExtension interface {
	ConfigView

	// SetSource installs the raw value source after construction, letting the composition
	// root build the adapter once the kernel Context already exists.
	SetSource(src ConfigSource)
	// Declare registers plugin-owned configuration bindings before Apply runs.
	Declare(pluginID string, bindings ...ConfigBinding) error
	// Bind resolves and assigns the configuration values for a tagged struct.
	Bind(prefix string, target any) error
	// Resolve computes the effective value of every declared key once.
	Resolve() error
	// Resolved reports whether Resolve has already run.
	Resolved() bool
	// Entries returns the redacted effective configuration ordered by key.
	Entries() []ConfigEntry
}

// ConfigRegistry implements ConfigExtension. Declarations are additive; values are
// computed once by Resolve and reused by every later read.
type ConfigRegistry struct {
	mu       sync.RWMutex
	src      ConfigSource
	decls    map[string]*configDecl
	order    []string
	values   map[string]any
	origins  map[string]string
	resolved bool
}

// NewConfigRegistry creates an empty configuration registry. A nil src is allowed so
// that the kernel can construct the registry before the composition root injects one.
func NewConfigRegistry(src ConfigSource) *ConfigRegistry {
	return &ConfigRegistry{
		src:     src,
		decls:   make(map[string]*configDecl),
		values:  make(map[string]any),
		origins: make(map[string]string),
	}
}

// SetSource installs the raw value source. It is intended for the composition root,
// which builds the adapter after the kernel Context already exists.
func (r *ConfigRegistry) SetSource(src ConfigSource) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.src = src
}

// Declare registers every `config` tagged leaf of each binding's target struct.
// Repeated declarations of the same key are accepted only when their env, default,
// auto-enable and secret metadata agree; disagreement is ErrConfigConflict.
func (r *ConfigRegistry) Declare(pluginID string, bindings ...ConfigBinding) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	for _, b := range bindings {
		if err := r.declareBinding(pluginID, b); err != nil {
			return err
		}
	}
	return nil
}

func (r *ConfigRegistry) declareBinding(pluginID string, b ConfigBinding) error {
	target, err := bindingStruct(b.Target, b.Prefix)
	if err != nil {
		return err
	}

	fields, err := walkConfigFields(target.Type(), b.Prefix)
	if err != nil {
		return err
	}
	for _, f := range fields {
		if err := r.addDecl(pluginID, f); err != nil {
			return err
		}
	}
	return nil
}

// bindingStruct validates that a binding or bind target is a usable struct pointer.
func bindingStruct(target any, prefix string) (reflect.Value, error) {
	rv := reflect.ValueOf(target)
	if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct {
		return reflect.Value{}, fmt.Errorf("%w: prefix %q received %T", ErrConfigTarget, prefix, target)
	}
	return rv.Elem(), nil
}

// walkConfigFields collects leaf configuration declarations from `config` tagged fields.
// A field without a `config` tag is skipped, except for embedded structs which are
// recursed into so their own tags resolve under the same prefix.
func walkConfigFields(t reflect.Type, prefix string) ([]configField, error) {
	var out []configField

	for i := 0; i < t.NumField(); i++ {
		sf := t.Field(i)
		if sf.PkgPath != "" {
			continue
		}

		path := sf.Tag.Get("config")
		if path == "-" {
			continue
		}
		if path == "" {
			if sf.Type.Kind() == reflect.Struct && sf.Type != durationType {
				nested, err := walkConfigFields(sf.Type, prefix)
				if err != nil {
					return nil, err
				}
				out = append(out, nested...)
			}
			continue
		}

		out = append(out, configField{
			key:        joinKey(prefix, path),
			path:       path,
			env:        sf.Tag.Get("env"),
			autoEnable: sf.Tag.Get("autoEnable"),
			def:        sf.Tag.Get("default"),
			secret:     strings.EqualFold(sf.Tag.Get("secret"), "true"),
			typ:        sf.Type,
		})
	}

	return out, nil
}

func joinKey(prefix, path string) string {
	if prefix == "" {
		return path
	}
	return prefix + "." + path
}

// addDecl records one leaf, enforcing the shared-declaration consistency rule.
func (r *ConfigRegistry) addDecl(pluginID string, f configField) error {
	if existing, ok := r.decls[f.key]; ok {
		if existing.env != f.env || existing.def != f.def ||
			existing.autoEnable != f.autoEnable || existing.secret != f.secret {
			return fmt.Errorf(
				"%w: key %q declared by plugin %q and plugin %q with disagreeing env/default/autoEnable/secret metadata",
				ErrConfigConflict, f.key, existing.pluginID, pluginID)
		}
		return nil
	}

	r.decls[f.key] = &configDecl{
		key: f.key, pluginID: pluginID, env: f.env,
		autoEnable: f.autoEnable, def: f.def, secret: f.secret, typ: f.typ,
	}
	r.order = append(r.order, f.key)
	return nil
}
  • Step 4: 补齐测试所需的占位实现

此时 Entries、Resolve、Bind、访问器尚未实现,Step 1 的测试用到 Entries。先创建 backend/core/extpoints/config_resolve.go 骨架,仅让 Entries 返回声明清单(值与来源在 Task 3/4 填充):

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0

package extpoints

import "sort"

// Entries returns the effective configuration as redacted, key-sorted entries.
func (r *ConfigRegistry) Entries() []ConfigEntry {
	r.mu.RLock()
	defer r.mu.RUnlock()

	keys := append([]string(nil), r.order...)
	sort.Strings(keys)

	out := make([]ConfigEntry, 0, len(keys))
	for _, key := range keys {
		d := r.decls[key]
		out = append(out, ConfigEntry{
			Key: d.key, PluginID: d.pluginID, Env: d.env,
			Origin: r.origins[key], Value: "pending",
		})
	}
	return out
}
  • Step 5: 运行测试确认通过

Run: cd backend && go test ./core/extpoints/ -run 'TestDeclare' -v Expected: --- PASS: TestDeclareRegistersTaggedLeafKeys、--- PASS: TestDeclareRejectsNonStructPointerTarget、--- PASS: TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata,ok Wavelet/core/extpoints。

  • Step 6: 格式与静态检查

Run: cd backend && golangci-lint fmt ./core/extpoints/ && golangci-lint run ./core/extpoints/ Expected: 无告警输出,退出码 0。

  • Step 7: 提交
git add backend/core/extpoints/config.go backend/core/extpoints/config_resolve.go backend/core/extpoints/config_test.go
git commit -m "feat(core): add configuration declaration registry"

Task 2: 值解码(标量、时长、切片、结构体)

Files:

  • Create: backend/core/extpoints/config_value.go

  • Test: backend/core/extpoints/config_test.go(追加)

  • Step 1: 追加失败的测试

在 backend/core/extpoints/config_test.go 末尾追加(fakeSource、redisConfig 复用 Task 1 的定义):

// queueConfig is a composite element mirroring worker.queues in config.yaml.
type queueConfig struct {
	Name     string `config:"name"`
	Priority int    `config:"priority"`
}

type workerConfig struct {
	Concurrency int           `config:"concurrency" env:"WORKER_CONCURRENCY"`
	Queues      []queueConfig `config:"queues"`
}

type sessionConfig struct {
	Secret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
	Age    int    `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
}

func TestResolveScalarDurationAndSlice(t *testing.T) {
	src := newFakeSource()
	src.values["redis.db"] = 1
	src.values["redis.dial_timeout"] = "5s"
	src.values["redis.addrs"] = []any{"127.0.0.1:6379"}
	src.env["REDIS_KEY_PREFIX"] = "refresh:"

	r := extpoints.NewConfigRegistry(src)
	require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
	require.NoError(t, r.Resolve())

	var got redisConfig
	require.NoError(t, r.Bind("redis", &got))
	assert.Equal(t, redisConfig{
		Addrs: []string{"127.0.0.1:6379"}, DB: 1, KeyPrefix: "refresh:", Dial: 5 * time.Second,
	}, got)
}

func TestResolveFillsSliceFromScalarEnvironmentValue(t *testing.T) {
	src := newFakeSource()
	src.env["REDIS_ADDR"] = "redis:6379"

	r := extpoints.NewConfigRegistry(src)
	require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
	require.NoError(t, r.Resolve())

	assert.Equal(t, []string{"redis:6379"}, r.Strings("redis.addrs"))
}

func TestResolveCompositeSliceOfStructs(t *testing.T) {
	src := newFakeSource()
	src.values["worker.concurrency"] = 20
	src.values["worker.queues"] = []any{
		map[string]any{"name": "webhook", "priority": 10},
		map[string]any{"name": "default", "priority": 3},
	}

	r := extpoints.NewConfigRegistry(src)
	require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))
	require.NoError(t, r.Resolve())

	var got workerConfig
	require.NoError(t, r.Bind("worker", &got))
	assert.Equal(t, workerConfig{
		Concurrency: 20,
		Queues:      []queueConfig{{Name: "webhook", Priority: 10}, {Name: "default", Priority: 3}},
	}, got)
}

func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) {
	src := newFakeSource()
	src.env["WORKER_CONCURRENCY"] = "many"

	r := extpoints.NewConfigRegistry(src)
	require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}}))

	err := r.Resolve()
	require.ErrorIs(t, err, extpoints.ErrConfigType)
	assert.Contains(t, err.Error(), "worker.concurrency")
	assert.Contains(t, err.Error(), "WORKER_CONCURRENCY")
}
  • Step 2: 运行测试确认失败

Run: cd backend && go test ./core/extpoints/ -run 'TestResolve' -v Expected: 编译失败,报 r.Resolve undefined、r.Bind undefined、r.Strings undefined。

  • Step 3: 写实现

创建 backend/core/extpoints/config_value.go:

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0

package extpoints

import (
	"fmt"
	"reflect"
	"strconv"
	"strings"
	"time"
)

// convertValue coerces a raw value coming from the configuration file or an
// environment variable into the declared Go type.
func convertValue(raw any, typ reflect.Type) (any, error) {
	if typ == durationType {
		return convertDuration(raw)
	}

	switch typ.Kind() {
	case reflect.Bool:
		return convertBool(raw)
	case reflect.String:
		return convertString(raw)
	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
		return convertInt(raw, typ)
	case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
		return convertUint(raw, typ)
	case reflect.Float32, reflect.Float64:
		return convertFloat(raw, typ)
	case reflect.Slice:
		return convertSlice(raw, typ)
	case reflect.Struct:
		return convertStruct(raw, typ)
	default:
		return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ)
	}
}

func convertBool(raw any) (any, error) {
	switch v := raw.(type) {
	case bool:
		return v, nil
	case string:
		parsed, err := strconv.ParseBool(strings.TrimSpace(v))
		if err != nil {
			return nil, fmt.Errorf("%w: %q is not a boolean", ErrConfigType, v)
		}
		return parsed, nil
	default:
		return nil, fmt.Errorf("%w: %v is not a boolean", ErrConfigType, raw)
	}
}

func convertString(raw any) (any, error) {
	switch v := raw.(type) {
	case string:
		return v, nil
	case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
		return fmt.Sprint(v), nil
	default:
		return nil, fmt.Errorf("%w: %v is not a string", ErrConfigType, raw)
	}
}

// numericString extracts the textual form of a value so environment overrides,
// which always arrive as strings, share one parsing path with file values.
func numericString(raw any) (string, bool) {
	switch v := raw.(type) {
	case string:
		return strings.TrimSpace(v), true
	case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
		return fmt.Sprint(v), true
	default:
		return "", false
	}
}

func convertInt(raw any, typ reflect.Type) (any, error) {
	text, ok := numericString(raw)
	if !ok {
		return nil, fmt.Errorf("%w: %v is not an integer", ErrConfigType, raw)
	}
	parsed, err := strconv.ParseInt(text, 10, typ.Bits())
	if err != nil {
		return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
	}
	out := reflect.New(typ).Elem()
	out.SetInt(parsed)
	return out.Interface(), nil
}

func convertUint(raw any, typ reflect.Type) (any, error) {
	text, ok := numericString(raw)
	if !ok {
		return nil, fmt.Errorf("%w: %v is not an unsigned integer", ErrConfigType, raw)
	}
	parsed, err := strconv.ParseUint(text, 10, typ.Bits())
	if err != nil {
		return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
	}
	out := reflect.New(typ).Elem()
	out.SetUint(parsed)
	return out.Interface(), nil
}

func convertFloat(raw any, typ reflect.Type) (any, error) {
	text, ok := numericString(raw)
	if !ok {
		return nil, fmt.Errorf("%w: %v is not a float", ErrConfigType, raw)
	}
	parsed, err := strconv.ParseFloat(text, typ.Bits())
	if err != nil {
		return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ)
	}
	out := reflect.New(typ).Elem()
	out.SetFloat(parsed)
	return out.Interface(), nil
}

// convertDuration accepts both Go duration strings such as "200ms" and integer
// nanoseconds, mirroring what the previous viper based decoding supported.
func convertDuration(raw any) (any, error) {
	switch v := raw.(type) {
	case time.Duration:
		return v, nil
	}

	text, ok := numericString(raw)
	if !ok {
		return nil, fmt.Errorf("%w: %v is not a duration", ErrConfigType, raw)
	}
	if parsed, err := time.ParseDuration(text); err == nil {
		return parsed, nil
	}
	nanos, err := strconv.ParseInt(text, 10, 64)
	if err != nil {
		return nil, fmt.Errorf("%w: %q is not a valid duration", ErrConfigType, text)
	}
	return time.Duration(nanos), nil
}

func convertSlice(raw any, typ reflect.Type) (any, error) {
	items, ok := sliceItems(raw)
	if !ok {
		// Scalar-to-single-element promotion keeps REDIS_ADDR populating redis.addrs.
		items = []any{raw}
	}

	out := reflect.MakeSlice(typ, 0, len(items))
	for _, item := range items {
		converted, err := convertValue(item, typ.Elem())
		if err != nil {
			return nil, err
		}
		out = reflect.Append(out, reflect.ValueOf(converted))
	}
	return out.Interface(), nil
}

// sliceItems normalises the several slice shapes a loader may produce.
func sliceItems(raw any) ([]any, bool) {
	switch v := raw.(type) {
	case []any:
		return v, true
	case []string:
		items := make([]any, len(v))
		for i, s := range v {
			items[i] = s
		}
		return items, true
	}

	rv := reflect.ValueOf(raw)
	if rv.IsValid() && rv.Kind() == reflect.Slice {
		items := make([]any, rv.Len())
		for i := 0; i < rv.Len(); i++ {
			items[i] = rv.Index(i).Interface()
		}
		return items, true
	}
	return nil, false
}

func convertStruct(raw any, typ reflect.Type) (any, error) {
	table, ok := asStringMap(raw)
	if !ok {
		return nil, fmt.Errorf("%w: %v is not a mapping, cannot decode into %s", ErrConfigType, raw, typ)
	}

	fields, err := walkConfigFields(typ, "")
	if err != nil {
		return nil, err
	}

	out := reflect.New(typ).Elem()
	for _, f := range fields {
		item, present := table[f.key]
		if !present || item == nil {
			continue
		}
		converted, err := convertValue(item, f.typ)
		if err != nil {
			return nil, fmt.Errorf("%w: %s.%s: %w", ErrConfigType, typ.Name(), f.key, err)
		}
		out.FieldByName(indexFieldName(typ, f.path)).Set(reflect.ValueOf(converted))
	}
	return out.Interface(), nil
}

// asStringMap normalises the two map shapes produced by YAML decoders.
func asStringMap(raw any) (map[string]any, bool) {
	switch v := raw.(type) {
	case map[string]any:
		return v, true
	case map[any]any:
		out := make(map[string]any, len(v))
		for key, val := range v {
			name, ok := key.(string)
			if !ok {
				return nil, false
			}
			out[name] = val
		}
		return out, true
	default:
		return nil, false
	}
}

// indexFieldName maps a declared config path back to the Go struct field carrying it.
func indexFieldName(t reflect.Type, key string) string {
	for i := 0; i < t.NumField(); i++ {
		if t.Field(i).Tag.Get("config") == key {
			return t.Field(i).Name
		}
	}
	return ""
}
  • Step 4: 写解析与绑定实现

在 backend/core/extpoints/config_resolve.go 末尾追加下列实现(保留 Task 1 写入的文件头、Entries 占位与 sort import;本步骤新增用到 errors、fmt、reflect,不要引入 sync/time):

// Resolve computes the effective value of every declared key. Priority is, in order:
// an explicit environment override, an auto-enable trigger, the configuration file,
// then the declared default. Resolution is idempotent; later declarations resolve lazily.
func (r *ConfigRegistry) Resolve() error {
	r.mu.Lock()
	defer r.mu.Unlock()

	if r.src == nil {
		return ErrConfigNoSource
	}

	var errs []error
	for _, key := range r.order {
		if _, done := r.values[key]; done {
			continue
		}
		if err := r.resolveLocked(key); err != nil {
			errs = append(errs, err)
		}
	}
	r.resolved = true

	return errors.Join(errs...)
}

// Resolved reports whether Resolve has already run.
func (r *ConfigRegistry) Resolved() bool {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.resolved
}

// resolveLocked computes one key. The caller must hold r.mu.
func (r *ConfigRegistry) resolveLocked(key string) error {
	d, ok := r.decls[key]
	if !ok {
		return fmt.Errorf("%w: %s", ErrConfigUnknownKey, key)
	}

	if d.env != "" {
		if raw, found := r.src.LookupEnv(d.env); found {
			value, err := convertValue(raw, d.typ)
			if err != nil {
				return fmt.Errorf("%w: key %q from environment %s: %w", ErrConfigType, key, d.env, err)
			}
			r.values[key], r.origins[key] = value, OriginEnv
			return nil
		}
	}

	if d.autoEnable != "" && d.typ.Kind() == reflect.Bool {
		if _, found := r.src.LookupEnv(d.autoEnable); found {
			r.values[key], r.origins[key] = true, OriginAutoEnable
			return nil
		}
	}

	if raw, found := r.src.Lookup(key); found {
		value, err := convertValue(raw, d.typ)
		if err != nil {
			return fmt.Errorf("%w: key %q from %s: %w", ErrConfigType, key, r.src.Describe(), err)
		}
		r.values[key], r.origins[key] = value, OriginFile
		return nil
	}

	if d.def != "" {
		value, err := convertValue(d.def, d.typ)
		if err != nil {
			return fmt.Errorf("%w: default %q for key %q: %w", ErrConfigType, d.def, key, err)
		}
		r.values[key], r.origins[key] = value, OriginDefault
		return nil
	}

	r.values[key] = reflect.New(d.typ).Elem().Interface()
	r.origins[key] = ""
	return nil
}

// Bind resolves the tagged fields of target and assigns them in place. Prefixes that
// were never declared self-register, so only gates need DeclareConfig.
func (r *ConfigRegistry) Bind(prefix string, target any) error {
	r.mu.Lock()
	defer r.mu.Unlock()

	if r.src == nil {
		return ErrConfigNoSource
	}
	if !r.resolved {
		return fmt.Errorf("%w: Bind(%q, %T) ran before App.Prepare", ErrConfigNotResolved, prefix, target)
	}

	elem, err := bindingStruct(target, prefix)
	if err != nil {
		return err
	}
	fields, err := walkConfigFields(elem.Type(), prefix)
	if err != nil {
		return err
	}

	for _, f := range fields {
		if _, declared := r.decls[f.key]; !declared {
			if err := r.addDecl("bind:"+prefix, f); err != nil {
				return err
			}
		}
		if _, done := r.values[f.key]; !done {
			if err := r.resolveLocked(f.key); err != nil {
				return err
			}
		}
	}

	for _, f := range fields {
		value := r.values[f.key]
		field := elem.FieldByName(indexFieldName(elem.Type(), f.path))
		if !field.IsValid() || !field.CanSet() {
			return fmt.Errorf("%w: field for key %q is not settable", ErrConfigTarget, f.key)
		}
		rv := reflect.ValueOf(value)
		if !rv.Type().AssignableTo(field.Type()) {
			return fmt.Errorf("%w: key %q resolves to %s, field expects %s",
				ErrConfigType, f.key, rv.Type(), field.Type())
		}
		field.Set(rv)
	}
	return nil
}

注意:ErrConfigUnknownKey 等全部哨兵错误已在 Task 1 的 config.go 错误块中定义,本步骤不要重复声明。

  • Step 5: 运行测试确认通过

Run: cd backend && go test ./core/extpoints/ -run 'TestResolve|TestDeclare' -v Expected: 全部 --- PASS,ok Wavelet/core/extpoints。若报 Entries redeclared,说明 Step 4 误把整文件替换而非追加。

  • Step 6: 格式与静态检查

Run: cd backend && golangci-lint fmt ./core/extpoints/ && golangci-lint run ./core/extpoints/ Expected: 无告警。(convertValue 保持 8 个分支,若 cyclop 仍报复杂度过高,把 Slice/Struct 两分支拆成独立函数,不要放宽 lint 配置。)

  • Step 7: 提交
git add backend/core/extpoints/
git commit -m "feat(core): resolve declared configuration with env and file precedence"

Task 3: 只读访问器、泛型读取与脱敏导出

Files:

  • Modify: backend/core/extpoints/config_resolve.go(追加访问器)

  • Modify: backend/core/extpoints/config.go(Entries 用到的 secret 判断)

  • Create: backend/core/config.go

  • Test: backend/core/extpoints/config_test.go(追加)、backend/core/config_test.go

  • Step 1: 追加失败的测试

在 backend/core/extpoints/config_test.go 末尾追加:

func TestViewAccessorsAndOrigins(t *testing.T) {
	src := newFakeSource()
	src.values["redis.db"] = 1
	src.env["REDIS_ADDR"] = "redis:6379"
	src.env["REDIS_ENABLED"] = "false"

	r := extpoints.NewConfigRegistry(src)
	require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
	require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}}))
	require.NoError(t, r.Resolve())

	assert.Equal(t, extpoints.OriginEnv, r.Origin("redis.addrs"))
	assert.Equal(t, "redis:6379", r.Strings("redis.addrs")[0])
	assert.False(t, r.Bool("redis.enabled", true))
	assert.Equal(t, 1, r.Int("redis.db", 0))
	assert.Equal(t, "86400", r.String("redis.missing", "86400"))
	assert.True(t, r.WasSet("REDIS_ADDR"))
	assert.False(t, r.WasSet("REDIS_NOPE"))
}

func TestAutoEnableBeatsFileValueButLosesToExplicitEnv(t *testing.T) {
	src := newFakeSource()
	src.env["REDIS_ADDR"] = "redis:6379"
	src.values["redis.enabled"] = false

	r := extpoints.NewConfigRegistry(src)
	require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
	require.NoError(t, r.Resolve())
	assert.True(t, r.Bool("redis.enabled", false), "REDIS_ADDR presence implies enabled")
	assert.Equal(t, extpoints.OriginAutoEnable, r.Origin("redis.enabled"))

	explicit := newFakeSource()
	explicit.env["REDIS_ADDR"] = "redis:6379"
	explicit.env["REDIS_ENABLED"] = "false"

	r2 := extpoints.NewConfigRegistry(explicit)
	require.NoError(t, r2.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))
	require.NoError(t, r2.Resolve())
	assert.False(t, r2.Bool("redis.enabled", true), "explicit REDIS_ENABLED must win over auto-enable")
	assert.Equal(t, extpoints.OriginEnv, r2.Origin("redis.enabled"))
}

func TestEntriesRedactSecretsAndReportDefaults(t *testing.T) {
	r := extpoints.NewConfigRegistry(newFakeSource())
	require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}}))
	require.NoError(t, r.Resolve())

	entries := map[string]extpoints.ConfigEntry{}
	for _, e := range r.Entries() {
		entries[e.Key] = e
	}

	assert.Equal(t, extpoints.RedactedValue, entries["app.session_secret"].Value)
	assert.Equal(t, extpoints.OriginDefault, entries["app.session_age"].Origin)
	assert.Equal(t, "86400", entries["app.session_age"].Value)
}

func TestBindRejectsReadsBeforeSourceIsRegistered(t *testing.T) {
	r := extpoints.NewConfigRegistry(nil)
	require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}}))

	assert.ErrorIs(t, r.Resolve(), extpoints.ErrConfigNoSource)

	var cfg redisConfig
	assert.ErrorIs(t, r.Bind("redis", &cfg), extpoints.ErrConfigNoSource)
}

新增脱敏常量到 backend/core/extpoints/config.go(Task 1 未定义它,因为此处才首次使用):

// RedactedValue replaces the printed value of keys declared with secret:"true".
const RedactedValue = "******"
  • Step 2: 运行测试确认失败

Run: cd backend && go test ./core/extpoints/ -run 'TestView|TestAutoEnable|TestEntries|TestBindRejects' -v Expected: 编译失败,报 r.Bool undefined、extpoints.RedactedValue undefined 等;Entries 已有但返回 "pending" 占位,故 TestEntriesRedactSecretsAndReportDefaults 亦失败。

  • Step 3: 实现访问器

在 backend/core/extpoints/config_resolve.go 末尾追加:

// Value returns the resolved value for key, lazily resolving it when a source is
// available. Unresolvable and missing keys report false rather than an error so
// gates and diagnostics can keep using fallback accessors.
func (r *ConfigRegistry) Value(key string) (any, bool) {
	r.mu.Lock()
	defer r.mu.Unlock()

	if r.src == nil {
		value, ok := r.values[key]
		return value, ok
	}
	if _, done := r.values[key]; !done {
		if _, declared := r.decls[key]; !declared {
			return nil, false
		}
		if err := r.resolveLocked(key); err != nil {
			return nil, false
		}
	}
	value, ok := r.values[key]
	return value, ok
}

// String returns the string value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) String(key, fallback string) string {
	if value, ok := r.Value(key); ok {
		if converted, err := convertString(value); err == nil {
			return converted.(string)
		}
	}
	return fallback
}

// Bool returns the boolean value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) Bool(key string, fallback bool) bool {
	if value, ok := r.Value(key); ok {
		if converted, err := convertBool(value); err == nil {
			return converted.(bool)
		}
	}
	return fallback
}

// Int returns the int value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) Int(key string, fallback int) int {
	if value, ok := r.Value(key); ok {
		if converted, err := convertInt(value, reflect.TypeFor[int]()); err == nil {
			return int(converted.(int))
		}
	}
	return fallback
}

// Duration returns the time.Duration value of key or fallback when absent or mismatched.
func (r *ConfigRegistry) Duration(key string, fallback time.Duration) time.Duration {
	if value, ok := r.Value(key); ok {
		if converted, err := convertDuration(value); err == nil {
			return converted.(time.Duration)
		}
	}
	return fallback
}

// Strings returns the []string value of key, or nil when absent.
func (r *ConfigRegistry) Strings(key string) []string {
	value, ok := r.Value(key)
	if !ok {
		return nil
	}
	converted, err := convertSlice(value, reflect.TypeFor[[]string]())
	if err != nil {
		return nil
	}
	list, _ := converted.([]string)
	return list
}

// WasSet reports whether an environment variable is present, regardless of its value.
func (r *ConfigRegistry) WasSet(envName string) bool {
	r.mu.RLock()
	defer r.mu.RUnlock()
	if r.src == nil {
		return false
	}
	_, found := r.src.LookupEnv(envName)
	return found
}

// Origin reports where a key's effective value came from; "" means the zero value.
func (r *ConfigRegistry) Origin(key string) string {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.origins[key]
}

把 Entries 的占位实现替换为真实值与脱敏:

// Entries returns the effective configuration as redacted, key-sorted entries.
func (r *ConfigRegistry) Entries() []ConfigEntry {
	r.mu.Lock()
	defer r.mu.Unlock()

	keys := append([]string(nil), r.order...)
	sort.Strings(keys)

	out := make([]ConfigEntry, 0, len(keys))
	for _, key := range keys {
		d := r.decls[key]
		if _, done := r.values[key]; !done && r.src != nil {
			_ = r.resolveLocked(key)
		}
		out = append(out, ConfigEntry{
			Key:      d.key,
			PluginID: d.pluginID,
			Env:      d.env,
			Origin:   r.origins[key],
			Value:    formatEntryValue(r.values[key], d.secret),
		})
	}
	return out
}

// formatEntryValue renders one effective value for diagnostics, masking secrets.
func formatEntryValue(value any, secret bool) string {
	if secret {
		return RedactedValue
	}
	if value == nil {
		return ""
	}
	return fmt.Sprint(value)
}
  • Step 4: 实现泛型读取入口

创建 backend/core/config.go:

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0

package core

import (
	"fmt"

	"Wavelet/core/extpoints"
)

// ConfigGet reads one resolved configuration value with its declared type. It is the
// generic counterpart of the fallback accessors on ConfigView, and returns
// ErrConfigNotResolved when the key has neither been declared nor resolved.
func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) {
	var zero T
	if view == nil {
		return zero, extpoints.ErrConfigNotResolved
	}

	raw, ok := view.Value(key)
	if !ok {
		return zero, fmt.Errorf("%w: %s", extpoints.ErrConfigUnknownKey, key)
	}

	value, ok := raw.(T)
	if !ok {
		return zero, fmt.Errorf("%w: key %q holds %T, want %T", extpoints.ErrConfigType, key, raw, zero)
	}
	return value, nil
}

创建 backend/core/config_test.go:

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0

package core_test

import (
	"Wavelet/core"
	"Wavelet/core/extpoints"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

type otelConfig struct {
	SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
}

func TestConfigGetReturnsDeclaredType(t *testing.T) {
	r := extpoints.NewConfigRegistry(nil)
	require.NoError(t, r.Declare("host", extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}}))

	rate, err := core.ConfigGet[float64](r, "otel.sampling_rate")
	require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey)
	assert.Equal(t, 0.0, rate)
}
  • Step 5: 运行测试确认通过

Run: cd backend && go test ./core/... -run 'TestView|TestAutoEnable|TestEntries|TestBindRejects|TestConfigGet' -v Expected: 全部 --- PASS。

  • Step 6: 格式与静态检查

Run: cd backend && golangci-lint fmt ./core/... && golangci-lint run ./core/... Expected: 无告警。若 dupl 因 convertInt/convertUint 结构相似报警,为其中之一加注释说明类型不同不可合并,或拆出公共 reflect 设置函数;不得关闭 dupl。

  • Step 7: 提交
git add backend/core/
git commit -m "feat(core): add read-only config view accessors and generic getter"

Task 4: 把配置注册表挂到 Context 并在 types.go 导出别名

Files:

  • Modify: backend/core/context.go(config 字段、NewContext、Fork、Config())

  • Modify: backend/core/types.go(别名)

  • Test: backend/core/config_test.go(追加)、backend/core/context_test.go(追加断言)

  • Step 1: 追加失败的测试

在 backend/core/config_test.go 末尾追加:

func TestContextConfigIsSharedAcrossForks(t *testing.T) {
	ctx := core.NewContext(nil)
	child := ctx.Fork()

	require.NoError(t, child.Config().Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &otelConfig{}}))

	rate, err := core.ConfigGet[float64](ctx.Config(), "redis.sampling_rate")
	require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey)
	assert.Zero(t, rate)
	assert.False(t, ctx.Config().Resolved())
}

在 backend/core/context_test.go 中已有的 Context 构造测试里追加一行断言(沿用该文件现有测试函数与变量名):

	require.NotNil(t, ctx.Config(), "every Context must expose the configuration extension")
  • Step 2: 运行测试确认失败

Run: cd backend && go test ./core/ -run 'TestContextConfigIsSharedAcrossForks' -v Expected: 编译失败,报 ctx.Config undefined、core.ConfigBinding undefined(测试里改用 extpoints.ConfigBinding 后该项消除)。

  • Step 3: 写实现

backend/core/context.go 的 Context 结构体字段中,在 settings 之后加一行:

	settings   extpoints.SettingExtension
	config     extpoints.ConfigExtension

NewContext 的返回字面量中,在 settings: extpoints.NewSettingRegistry(), 之后加:

		config:     extpoints.NewConfigRegistry(nil),

ForkWithContext 的 child 字面量中,在 settings: c.settings, 之后加:

		config:     c.config,

在 Setting() 别名方法之后加访问器:

// Config returns the process-level configuration extension point. The registry is
// shared by every fork because configuration declarations are global facts, and it
// intentionally carries no per-scope disposers: values are resolved once before Apply.
func (c *Context) Config() extpoints.ConfigExtension {
	return c.config
}

backend/core/types.go 末尾追加别名与新接口:

// ConfigExtension re-exports extpoints.ConfigExtension.
type ConfigExtension = extpoints.ConfigExtension

// ConfigSource re-exports extpoints.ConfigSource.
type ConfigSource = extpoints.ConfigSource

// ConfigBinding re-exports extpoints.ConfigBinding.
type ConfigBinding = extpoints.ConfigBinding

// ConfigView re-exports extpoints.ConfigView.
type ConfigView = extpoints.ConfigView

// ConfigEntry re-exports extpoints.ConfigEntry.
type ConfigEntry = extpoints.ConfigEntry

// ConfigGatedPlugin is an optional interface for plugins whose activation depends on
// configuration. The kernel evaluates the gate before any Apply runs, so keys read by
// ConfigEnabled must be published through DeclareConfig.
type ConfigGatedPlugin interface {
	Plugin

	// DeclareConfig publishes the configuration bindings consumed by ConfigEnabled.
	DeclareConfig() []extpoints.ConfigBinding

	// ConfigEnabled reports whether this plugin should activate for the resolved values.
	ConfigEnabled(view extpoints.ConfigView) bool
}
  • Step 4: 运行测试确认通过

Run: cd backend && go test ./core/... -v -run 'TestContextConfig|TestFiber|TestApp|TestConfigGet' Expected: 新增用例 --- PASS,既有用例无回归。

  • Step 5: 提交
git add backend/core/context.go backend/core/types.go backend/core/config_test.go backend/core/context_test.go
git commit -m "feat(core): mount the configuration extension point on the kernel Context"

Task 5: Fiber 门禁跳过态

Files:

  • Modify: backend/core/fiber.go

  • Test: backend/core/fiber_test.go(追加)

  • Step 1: 追加失败的测试

在 backend/core/fiber_test.go 末尾追加:

// gatedPlugin is a minimal plugin used to exercise configuration gating.
type gatedPlugin struct {
	name    string
	enabled bool
	applied bool
}

func (g *gatedPlugin) Name() string { return g.name }
func (g *gatedPlugin) Apply(ctx *core.Context) error {
	g.applied = true
	return nil
}
func (g *gatedPlugin) DeclareConfig() []extpoints.ConfigBinding {
	return []extpoints.ConfigBinding{{Prefix: "gate", Target: &gateConfig{}}}
}
func (g *gatedPlugin) ConfigEnabled(view extpoints.ConfigView) bool {
	return view.Bool("gate.enabled", false) == g.enabled
}

type gateConfig struct {
	Enabled bool `config:"enabled" env:"GATE_ENABLED"`
}

func TestFiberSkipMovesToSkippedStateAndDisposesScope(t *testing.T) {
	root := core.NewContext(nil)
	plugin := &gatedPlugin{name: "cache", enabled: true}
	f := core.NewFiber(root, plugin)
	require.Equal(t, core.FiberPending, f.State())

	require.NoError(t, f.Skip())

	assert.Equal(t, core.FiberSkipped, f.State())
	assert.True(t, f.Skipped())
	assert.False(t, plugin.applied, "a skipped plugin must never reach Apply")
	assert.NoError(t, f.Unload(), "unloading a skipped fiber is a no-op")
}

func TestFiberSkipIsIdempotentForActiveFibers(t *testing.T) {
	root := core.NewContext(nil)
	f := core.NewFiber(root, &gatedPlugin{name: "cache", enabled: true})
	require.NoError(t, f.Load())

	require.NoError(t, f.Skip())
	assert.Equal(t, core.FiberActive, f.State(), "Skip only applies to pending fibers")
}
  • Step 2: 运行测试确认失败

Run: cd backend && go test ./core/ -run 'TestFiberSkip' -v Expected: 编译失败,报 undefined: core.FiberSkipped、f.Skip undefined、f.Skipped undefined。

  • Step 3: 写实现

backend/core/fiber.go 的 FiberState 常量块中,在 FiberDisposed 之后追加一个状态:

	// FiberSkipped indicates the plugin never activated because its configuration gate
	// evaluated to false, so an alternative provider took over.
	FiberSkipped FiberState = "SKIPPED"

Load 之后追加 Skip:

// Skip transitions a pending plugin to FiberSkipped and releases its scoped Context.
// Active plugins are left untouched, making the call safe to replay during reconcile.
func (f *Fiber) Skip() error {
	f.mu.Lock()
	if f.state != FiberPending {
		f.mu.Unlock()
		return nil
	}
	f.state = FiberSkipped
	f.mu.Unlock()

	return f.ctx.Dispose()
}

// Skipped reports whether the plugin was excluded by its configuration gate.
func (f *Fiber) Skipped() bool {
	return f.State() == FiberSkipped
}

不要改 DependenciesSatisfied:依赖能否满足完全由 IoC 容器解析决定。被跳过的插件从未执行 Apply,也就没有 core.Provide,其消费者自然解析不到服务并在 Reconcile 里报"waiting for"。用 Fiber 状态做短路是错误语义。

  • Step 4: 运行测试确认通过

Run: cd backend && go test ./core/ -run 'TestFiber' -v Expected: 新增两个用例 --- PASS,既有 TestFiber_ConfluenceAndReactiveActivation、TestFiber_UnsatisfiedDependencyReturnsError 无回归。

  • Step 5: 提交
git add backend/core/fiber.go backend/core/fiber_test.go
git commit -m "feat(core): add skipped fiber state for configuration gates"

Task 6: App 装配选项、解析屏障与门禁求值

Files:

  • Modify: backend/core/app.go

  • Test: backend/core/app_test.go(追加)

  • Step 1: 追加失败的测试

在 backend/core/app_test.go 末尾追加(复用 Task 5 的 gatedPlugin/gateConfig;mapSource 为本测试自备的内存源):

// mapSource implements core.ConfigSource over static maps.
type mapSource struct {
	values map[string]any
	env    map[string]string
}

func (m *mapSource) Lookup(path string) (any, bool) {
	v, ok := m.values[path]
	return v, ok
}
func (m *mapSource) LookupEnv(name string) (string, bool) {
	v, ok := m.env[name]
	return v, ok
}
func (m *mapSource) Describe() string { return "map" }

func newGateSource(enabled bool) *mapSource {
	src := &mapSource{values: map[string]any{"gate.enabled": enabled}, env: map[string]string{}}
	return src
}

func TestAppPrepareResolvesAndGatesPlugins(t *testing.T) {
	redisLike := &gatedPlugin{name: "cache", enabled: true}
	redisAlt := &gatedPlugin{name: "cache_memory", enabled: false}

	app := core.NewApp(
		core.WithProfile(core.ProfileAPI),
		core.WithConfigSource(newGateSource(true)),
	)
	app.Use(redisLike, redisAlt)
	require.NoError(t, app.Prepare())

	cacheFiber, ok := app.Fiber("cache")
	require.True(t, ok)
	require.Equal(t, core.FiberPending, cacheFiber.State(), "Prepare only builds the resolution barrier")
	assert.True(t, app.Context().Config().Resolved())

	require.NoError(t, app.Reconcile())

	require.Equal(t, core.FiberActive, cacheFiber.State())

	memoryFiber, ok := app.Fiber("cache_memory")
	require.True(t, ok)
	assert.Equal(t, core.FiberSkipped, memoryFiber.State())
	assert.False(t, redisAlt.applied)
}

func TestAppGatesPluginsMountedAfterPrepare(t *testing.T) {
	app := core.NewApp(core.WithConfigSource(newGateSource(true)))
	require.NoError(t, app.Prepare())

	late := &gatedPlugin{name: "cache_memory", enabled: false}
	app.Use(late)
	require.NoError(t, app.Reconcile())

	fiber, ok := app.Fiber("cache_memory")
	require.True(t, ok)
	assert.Equal(t, core.FiberSkipped, fiber.State(),
		"plugins added after Prepare must still be gated")
}

func TestAppApplyPluginsGatesImplicitly(t *testing.T) {
	redisLike := &gatedPlugin{name: "cache", enabled: true}
	app := core.NewApp(core.WithConfigSource(newGateSource(false)))
	app.Use(redisLike)

	require.NoError(t, app.ApplyPlugins())

	fiber, ok := app.Fiber("cache")
	require.True(t, ok)
	assert.Equal(t, core.FiberSkipped, fiber.State(), "ApplyPlugins must resolve and gate implicitly")
}

func TestAppPrepareReportsConfigurationErrors(t *testing.T) {
	src := &mapSource{values: map[string]any{"gate.enabled": "yes"}, env: map[string]string{}}
	app := core.NewApp(core.WithConfigSource(src))
	app.Use(&gatedPlugin{name: "cache", enabled: true})

	err := app.Prepare()
	require.Error(t, err)
	assert.Contains(t, err.Error(), "gate.enabled")
}

func TestAppSetShutdownTimeoutOverridesDefault(t *testing.T) {
	app := core.NewApp()
	app.SetShutdownTimeout(0)
	assert.NotZero(t, app.ShutdownTimeout(), "zero durations must not shrink the kernel fallback")

	app.SetShutdownTimeout(45 * time.Second)
	assert.Equal(t, 45*time.Second, app.ShutdownTimeout())
}
  • Step 2: 运行测试确认失败

Run: cd backend && go test ./core/ -run 'TestAppPrepare|TestAppStartRunsPrepare|TestAppSetShutdown' -v Expected: 编译失败,报 core.WithConfigSource undefined、app.Prepare undefined、app.ShutdownTimeout undefined。

  • Step 3: 写实现

backend/core/app.go 中,App 结构体追加两个字段:

	migrationEngine MigrationEngine
	shutdownTimeout time.Duration
	configSource    ConfigSource
	prepared        bool

AppOption 区追加选项(放在 WithShutdownTimeout 之后):

// WithConfigSource installs the raw configuration source adapter, typically built by
// an infrastructure package outside the kernel, before any plugin is applied.
func WithConfigSource(src ConfigSource) AppOption {
	return func(a *App) {
		if src == nil {
			return
		}
		a.configSource = src
		a.ctx.Config().SetSource(src)
	}
}

// WithConfigDecl lets the composition root declare the configuration it reads itself,
// so host-level values participate in conflict validation and redacted reporting.
func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption {
	return func(a *App) {
		if len(bindings) == 0 {
			return
		}
		if err := a.ctx.Config().Declare(pluginID, bindings...); err != nil {
			a.applyErr = err
		}
	}
}

App 结构体再加 applyErr error 字段,并在 NewApp 末尾返回前保持原逻辑(applyErr 由 Prepare 首次上报)。

追加 Prepare、ShutdownTimeout、SetShutdownTimeout:

// Prepare resolves declared configuration and evaluates plugin gates. It is idempotent
// and runs implicitly from ApplyPlugins, so callers that need resolved values earlier
// (for example to size a shutdown budget) can invoke it explicitly.
func (a *App) Prepare() error {
	a.mu.Lock()
	defer a.mu.Unlock()

	if err := a.applyErr; err != nil {
		return err
	}
	return a.prepareLocked()
}

func (a *App) prepareLocked() error {
	if a.prepared {
		return nil
	}

	if err := a.ctx.Config().Resolve(); err != nil {
		return err
	}
	a.prepared = true

	return nil
}

// ShutdownTimeout returns the graceful shutdown budget for the application.
func (a *App) ShutdownTimeout() time.Duration {
	a.mu.RLock()
	defer a.mu.RUnlock()
	return a.shutdownTimeout
}

// SetShutdownTimeout replaces the graceful shutdown budget, ignoring non-positive values.
func (a *App) SetShutdownTimeout(timeout time.Duration) *App {
	a.mu.Lock()
	defer a.mu.Unlock()
	if timeout > 0 {
		a.shutdownTimeout = timeout
	}
	return a
}

门禁为什么在 reconcileLocked 内求值而不是 Prepare 里一次性遍历:App.Use 可以在 Prepare 之后继续挂载插件(下游定制与动态装配)。只在 Prepare 求值会留下一批永不判定的门禁;放在调和循环里则任何时刻新挂载的插件都会被正确判定,且 Fiber.Skip 自带"仅 Pending 可跳过"守卫,重复遍历安全。

Use 中,为每个成功登记的插件收集声明(放在 a.pluginMap[name] = p 之前):

		if gated, ok := p.(ConfigGatedPlugin); ok {
			if err := a.ctx.Config().Declare(name, gated.DeclareConfig()...); err != nil {
				if a.applyErr == nil {
					a.applyErr = err
				}
			}
		}

ApplyPlugins 与 reconcileLocked 接入屏障(ApplyPlugins 已持锁,改调用 prepareLocked):

func (a *App) ApplyPlugins() error {
	a.mu.Lock()
	if a.applied {
		a.mu.Unlock()
		return nil
	}
	a.applied = true

	if err := a.applyErr; err != nil {
		a.mu.Unlock()
		return err
	}
	if err := a.prepareLocked(); err != nil {
		a.mu.Unlock()
		return err
	}
	a.mu.Unlock()

	return a.Reconcile()
}

把 reconcileLocked 的内层循环替换为带门禁判定的版本(其余保持不变):

func (a *App) reconcileLocked() error {
	if err := a.prepareLocked(); err != nil {
		return err
	}

	view := a.ctx.Config()

	for {
		progress := false
		for _, f := range a.fibers {
			if f.State() != FiberPending {
				continue
			}

			if gated, ok := f.plugin.(ConfigGatedPlugin); ok {
				if !view.Resolved() {
					continue
				}
				if !gated.ConfigEnabled(view) {
					if err := f.Skip(); err != nil {
						return fmt.Errorf("core: skip gated plugin %q: %w", f.Name(), err)
					}
					continue
				}
			}

			if f.DependenciesSatisfied(a.ctx) {
				if err := f.Load(); err != nil {
					return fmt.Errorf("core: load fiber %q failed: %w", f.Name(), err)
				}
				progress = true
			}
		}
		if !progress {
			break
		}
	}

	// ...existing unsatisfied-dependency reporting unchanged
}

unsatisfied 收集循环无需改动:它只统计 FiberPending,被门禁排除的插件已是 FiberSkipped。

  • Step 4: 运行测试确认通过

Run: cd backend && go test ./core/ -v Expected: 全部 --- PASS,包括既有 TestApp*、TestFiber*、TestContext*。

  • Step 5: 全量回归(应用行为必须不变)

Run: cd backend && go test ./... && go build -o /dev/null ./... Expected: 全绿。此时业务插件与 cmd 仍走旧的全局单例,因此运行行为与迁移前完全一致——这是本计划的关键安全属性。

  • Step 6: 格式与静态检查

Run: cd backend && golangci-lint fmt ./core/... && golangci-lint run ./core/... Expected: 无告警。

  • Step 7: 提交
git add backend/core/app.go backend/core/app_test.go
git commit -m "feat(core): add config resolution barrier and plugin gating to App"

Task 7: viper 配置源适配器(plugins/infra/config)

Files:

  • Create: backend/plugins/infra/config/source.go

  • Create: backend/plugins/infra/config/source_test.go

  • Step 1: 写失败的测试

创建 backend/plugins/infra/config/source_test.go:

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0

package config_test

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"Wavelet/plugins/infra/config"
)

const sampleYAML = "" +
	"app:\n  addr: \":8000\"\n  node_id: 1\n" +
	"database:\n  enabled: false\n  port: 5432\n  slow_threshold: 200ms\n" +
	"redis:\n  addrs:\n    - \"127.0.0.1:6379\"\n"

func writeConfig(t *testing.T) string {
	t.Helper()

	dir := t.TempDir()
	path := filepath.Join(dir, "config.yaml")
	require.NoError(t, os.WriteFile(path, []byte(sampleYAML), 0o600))
	return path
}

func TestSourceLooksUpNestedPaths(t *testing.T) {
	src, err := config.NewSource(config.WithPath(writeConfig(t)))
	require.NoError(t, err)

	value, ok := src.Lookup("database.port")
	require.True(t, ok)
	assert.Equal(t, 5432, value)

	_, ok = src.Lookup("database.missing")
	assert.False(t, ok)
}

func TestSourceTreatsUnsetFileAsEnvOnly(t *testing.T) {
	missing := filepath.Join(t.TempDir(), "absent.yaml")

	src, err := config.NewSource(config.WithPath(missing))
	require.NoError(t, err, "a missing configuration file must fall back to environment values")

	_, ok := src.Lookup("app.addr")
	assert.False(t, ok)
	assert.Equal(t, config.EnvOnlyOrigin, src.Describe())
}

func TestSourceRejectsMalformedFile(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "config.yaml")
	require.NoError(t, os.WriteFile(path, []byte("app: [unclosed\n"), 0o600))

	_, err := config.NewSource(config.WithPath(path))
	require.Error(t, err)
}

func TestSourceLookupEnvReadsProcessEnvironment(t *testing.T) {
	t.Setenv("WAVELET_SOURCE_PROBE", "present")

	src, err := config.NewSource(config.WithPath(writeConfig(t)))
	require.NoError(t, err)

	value, ok := src.LookupEnv("WAVELET_SOURCE_PROBE")
	require.True(t, ok)
	assert.Equal(t, "present", value)

	_, ok = src.LookupEnv("WAVELET_SOURCE_ABSENT")
	assert.False(t, ok)
}
  • Step 2: 运行测试确认失败

Run: cd backend && go test ./plugins/infra/config/ -v Expected: 编译失败,报 package Wavelet/plugins/infra/config is not in std / undefined: config.NewSource。

  • Step 3: 写实现

创建 backend/plugins/infra/config/source.go:

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0

// Package config adapts viper to the kernel configuration source contract. It is a
// runtime adapter rather than a core.Plugin: it owns no routes, services or tasks, and
// therefore never appears in app.Use. Keeping it out of core preserves the micro-kernel
// rule against importing concrete runtime dependencies.
package config

import (
	"errors"
	"fmt"
	"os"

	"github.com/spf13/viper"
)

// DefaultFileName is the configuration file looked up when CONFIG_PATH is unset.
const DefaultFileName = "config.yaml"

// EnvOnlyOrigin is reported by Describe when no configuration file was loaded.
const EnvOnlyOrigin = "<env only>"

// maxSearchDepth bounds the upward directory walk so a misconfigured working set
// cannot make the loader scan the whole filesystem.
const maxSearchDepth = 5

// Option configures a Source.
type Option func(*Source)

// WithPath pins the configuration file, bypassing CONFIG_PATH and the upward search.
func WithPath(path string) Option {
	return func(s *Source) {
		s.path = path
	}
}

// Source implements core.ConfigSource over a configuration file plus the process environment.
type Source struct {
	v     *viper.Viper
	path  string
	found bool
}

// NewSource loads the configuration file. A missing file is not an error: the source
// then serves environment values only, matching the previous pkg/config behaviour.
func NewSource(opts ...Option) (*Source, error) {
	s := &Source{}
	for _, opt := range opts {
		opt(s)
	}

	if s.path == "" {
		s.path = os.Getenv("CONFIG_PATH")
	}
	if s.path == "" {
		s.path = findConfigPath(DefaultFileName)
	}

	v := viper.New()
	v.SetConfigFile(s.path)

	err := v.ReadInConfig()
	switch {
	case err == nil:
		s.found = true
	case isNotFound(err):
		// fall through to environment-only lookups
	default:
		if _, statErr := os.Stat(s.path); statErr == nil { //nolint:gosec // s.path comes from CONFIG_PATH or a bounded upward search
			return nil, fmt.Errorf("infra/config: read %s: %w", s.path, err)
		}
	}

	s.v = v
	return s, nil
}

func isNotFound(err error) bool {
	var notFound viper.ConfigFileNotFoundError
	return errors.As(err, &notFound) || errors.Is(err, os.ErrNotExist)
}

// Lookup returns the raw value stored at a dotted path, or false when the file was not
// loaded or the path is absent.
func (s *Source) Lookup(path string) (any, bool) {
	if !s.found || !s.v.IsSet(path) {
		return nil, false
	}
	return s.v.Get(path), true
}

// LookupEnv reads a process environment variable.
func (s *Source) LookupEnv(name string) (string, bool) {
	return os.LookupEnv(name)
}

// Describe returns the loaded file path, or EnvOnlyOrigin when running on environment values.
func (s *Source) Describe() string {
	if !s.found {
		return EnvOnlyOrigin
	}
	return s.path
}

// findConfigPath searches upward from the working directory so tests and binaries run
// from backend/ still find the repository-root configuration file.
func findConfigPath(configPath string) string {
	if _, err := os.Stat(configPath); err == nil {
		return configPath
	}

	dir := "."
	for range maxSearchDepth {
		dir += "/.."
		path := dir + "/" + configPath
		if _, err := os.Stat(path); err == nil {
			return path
		}
	}
	return configPath
}
  • Step 4: 运行测试确认通过

Run: cd backend && go test ./plugins/infra/config/ -v Expected: 四个用例全部 --- PASS。

  • Step 5: 格式与静态检查

Run: cd backend && golangci-lint fmt ./plugins/infra/config/ && golangci-lint run ./plugins/infra/config/ Expected: 无告警。

  • Step 6: 提交
git add backend/plugins/infra/config/
git commit -m "feat(infra): add viper backed configuration source adapter"

Task 8: 旧配置包可重入重构 + 新旧对拍

Files:

  • Modify: backend/pkg/config/config.go:57-108

  • Create: backend/pkg/config/parity_test.go(临时文件,P4 随旧包一并删除)

  • Step 1: 重构旧加载器为可重入函数

把 backend/pkg/config/config.go 的 init() 拆成 load + init。原实现使用包级 viper 全局并在 init 里内联全部步骤,对拍需要能反复调用且不受 isTest() 干扰:

// load reads configuration from configPath, applies defaults and environment overrides,
// and optionally disables external services for in-test runs.
func load(configPath string, testMode bool) *configModel {
	v := viper.New()
	v.SetConfigFile(configPath)

	if err := v.ReadInConfig(); err != nil {
		var notFound viper.ConfigFileNotFoundError
		if !errors.As(err, &notFound) {
			if _, statErr := os.Stat(configPath); statErr == nil { //nolint:gosec // configPath is loaded from CONFIG_PATH environment variable
				log.Fatalf("[Config] read config failed: %v\n", err)
			}
		}
		log.Println("[Config] no config file found, using environment variables only")
		v.SetConfigType("yaml")
		if err := v.ReadConfig(strings.NewReader("")); err != nil {
			log.Fatalf("[Config] failed to init empty config: %v\n", err)
		}
	}

	var c configModel
	if err := v.Unmarshal(&c); err != nil {
		log.Fatalf("[Config] parse config failed: %v\n", err)
	}

	applyDefaults(&c)
	applyEnvOverrides(&c)
	applyDefaults(&c)

	if testMode {
		c.Database.Enabled = false
		c.Database.SQLitePath = ":memory:"
		c.Redis.Enabled = false
		c.ClickHouse.Enabled = false
	}

	return &c
}

func init() {
	configPath := os.Getenv("CONFIG_PATH")
	if configPath == "" {
		configPath = findConfigPath("config.yaml")
	}

	Config = load(configPath, isTest())

	printConfig(Config)
}

同步调整:import 增加 "errors";删除原 init 里的 viper.SetConfigFile/viper.AutomaticEnv/viper.ReadInConfig 等包级调用与 if _, ok := err.(viper.ConfigFileNotFoundError); !ok 断言(改用上面的 errors.As)。

行为等价说明(评审时核对):viper.AutomaticEnv() 只影响按键读取,Unmarshal 走的是 AllKeys,因此去掉 AutomaticEnv 不改变解析结果;环境变量覆盖仍由 applyEnvOverrides 负责。

  • Step 2: 运行旧测试确认无回归

Run: cd backend && go test ./pkg/config/ ./cmd/ -v -run 'TestApplyEnvOverrides|Test' 2>&1 | tail -30 Expected: pkg/config 的 TestApplyEnvOverridesRedisMaintNotifications 通过;cmd 包既有用例结果与改动前一致(先运行一次改动前的 go test ./cmd/ 记录基线)。

  • Step 3: 写对拍测试

创建 backend/pkg/config/parity_test.go:

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0

// Temporary migration harness: proves the new kernel configuration engine resolves
// every key identically to pkg/config before the legacy singleton is deleted in P4.
// Delete this file together with backend/pkg/config.
package config

import (
	"os"
	"path/filepath"
	"reflect"
	"testing"
	"time"

	"github.com/google/go-cmp/cmp"
	"github.com/spf13/viper"

	"Wavelet/core/extpoints"
)

// yamlSource is a test-local core.ConfigSource over the repository config file.
// It deliberately does not import plugins/infra/config: backend/pkg must not depend on
// upper layers even in tests, and the adapter has its own coverage in its package tests.
type yamlSource struct {
	v *viper.Viper
}

func newYAMLSource(t *testing.T, path string) *yamlSource {
	t.Helper()

	v := viper.New()
	v.SetConfigFile(path)
	require.NoError(t, v.ReadInConfig())
	return &yamlSource{v: v}
}

func (s *yamlSource) Lookup(path string) (any, bool) {
	if !s.v.IsSet(path) {
		return nil, false
	}
	return s.v.Get(path), true
}

func (s *yamlSource) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) }

func (s *yamlSource) Describe() string { return s.v.ConfigFileUsed() }

// engineAppConfig mirrors appConfig with engine tags.
type engineAppConfig struct {
	AppName                 string `config:"app_name" env:"APP_NAME"`
	Env                     string `config:"env" env:"APP_ENV"`
	Addr                    string `config:"addr" env:"APP_ADDR"`
	NodeID                  int64  `config:"node_id" env:"APP_NODE_ID"`
	APIPrefix               string `config:"api_prefix" env:"APP_API_PREFIX"`
	GracefulShutdownTimeout int    `config:"graceful_shutdown_timeout" env:"APP_GRACEFUL_SHUTDOWN_TIMEOUT"`
	SessionCookieName       string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME"`
	SessionSecret           string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
	SessionDomain           string `config:"session_domain" env:"APP_SESSION_DOMAIN"`
	SessionAge              int    `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
	SessionHTTPOnly         bool   `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY"`
	SessionSecure           bool   `config:"session_secure" env:"APP_SESSION_SECURE"`
}

type engineDatabaseConfig struct {
	Enabled                bool          `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"`
	SQLitePath             string        `config:"sqlite_path" env:"SQLITE_PATH"`
	Host                   string        `config:"host" env:"DB_HOST"`
	Port                   int           `config:"port" env:"DB_PORT"`
	Username               string        `config:"username" env:"DB_USERNAME"`
	Password               string        `config:"password" env:"DB_PASSWORD" secret:"true"`
	Database               string        `config:"database" env:"DB_NAME"`
	MaxIdleConn            int           `config:"max_idle_conn" env:"DB_MAX_IDLE_CONN"`
	MaxOpenConn            int           `config:"max_open_conn" env:"DB_MAX_OPEN_CONN"`
	ConnMaxLifetime        int           `config:"conn_max_lifetime" env:"DB_CONN_MAX_LIFETIME"`
	ConnMaxIdleTime        int           `config:"conn_max_idle_time" env:"DB_CONN_MAX_IDLE_TIME"`
	LogLevel               string        `config:"log_level" env:"DB_LOG_LEVEL"`
	SSLMode                string        `config:"ssl_mode" env:"DB_SSL_MODE"`
	TimeZone               string        `config:"time_zone" env:"DB_TIMEZONE"`
	ApplicationName        string        `config:"application_name" env:"DB_APPLICATION_NAME"`
	SearchPath             string        `config:"search_path" env:"DB_SEARCH_PATH"`
	PreferSimpleProtocol   bool          `config:"prefer_simple_protocol" env:"DB_PREFER_SIMPLE_PROTOCOL"`
	StatementCacheCapacity int           `config:"statement_cache_capacity" env:"DB_STATEMENT_CACHE_CAPACITY"`
	DefaultQueryExecMode   string        `config:"default_query_exec_mode" env:"DB_DEFAULT_QUERY_EXEC_MODE"`
	Replicas               []engineReplicaConfig `config:"replicas"`
	SlowThreshold          time.Duration `config:"slow_threshold" env:"DB_SLOW_THRESHOLD"`
}

type engineRedisConfig struct {
	Enabled            bool     `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"`
	Addrs              []string `config:"addrs" env:"REDIS_ADDR"`
	Username           string   `config:"username" env:"REDIS_USERNAME"`
	Password           string   `config:"password" env:"REDIS_PASSWORD" secret:"true"`
	DB                 int      `config:"db" env:"REDIS_DB"`
	ClusterMode        bool     `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"`
	MasterName         string   `config:"master_name" env:"REDIS_MASTER_NAME"`
	KeyPrefix          string   `config:"key_prefix" env:"REDIS_KEY_PREFIX"`
	PoolSize           int      `config:"pool_size" env:"REDIS_POOL_SIZE"`
	MinIdleConn        int      `config:"min_idle_conn" env:"REDIS_MIN_IDLE_CONN"`
	DialTimeout        int      `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"`
	ReadTimeout        int      `config:"read_timeout" env:"REDIS_READ_TIMEOUT"`
	WriteTimeout       int      `config:"write_timeout" env:"REDIS_WRITE_TIMEOUT"`
	MaxRetries         int      `config:"max_retries" env:"REDIS_MAX_RETRIES"`
	PoolTimeout        int      `config:"pool_timeout" env:"REDIS_POOL_TIMEOUT"`
	ConnMaxIdleTime    int      `config:"conn_max_idle_time" env:"REDIS_CONN_MAX_IDLE_TIME"`
	MaintNotifications bool     `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS"`
}

type engineClickHouseConfig struct {
	Enabled         bool     `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"`
	Hosts           []string `config:"hosts" env:"CLICKHOUSE_HOST"`
	Username        string   `config:"username" env:"CLICKHOUSE_USERNAME"`
	Password        string   `config:"password" env:"CLICKHOUSE_PASSWORD" secret:"true"`
	Database        string   `config:"database" env:"CLICKHOUSE_NAME"`
	MaxIdleConn     int      `config:"max_idle_conn" env:"CLICKHOUSE_MAX_IDLE_CONN"`
	MaxOpenConn     int      `config:"max_open_conn" env:"CLICKHOUSE_MAX_OPEN_CONN"`
	ConnMaxLifetime int      `config:"conn_max_lifetime" env:"CLICKHOUSE_CONN_MAX_LIFETIME"`
	DialTimeout     int      `config:"dial_timeout" env:"CLICKHOUSE_DIAL_TIMEOUT"`
	BlockBufferSize uint8    `config:"block_buffer_size" env:"CLICKHOUSE_BLOCK_BUFFER_SIZE"`
}

type engineLogConfig struct {
	Level      string `config:"level" env:"LOG_LEVEL"`
	Format     string `config:"format" env:"LOG_FORMAT"`
	Output     string `config:"output" env:"LOG_OUTPUT"`
	FilePath   string `config:"file_path" env:"LOG_FILE_PATH"`
	MaxSize    int    `config:"max_size" env:"LOG_MAX_SIZE"`
	MaxAge     int    `config:"max_age" env:"LOG_MAX_AGE"`
	MaxBackups int    `config:"max_backups" env:"LOG_MAX_BACKUPS"`
	Compress   bool   `config:"compress" env:"LOG_COMPRESS"`
}

type engineOtelConfig struct {
	SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"`
	TracerName   string  `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"`
}

// engineReplicaConfig mirrors databaseReplicaConfig, a composite element of database.replicas.
type engineReplicaConfig struct {
	Host     string `config:"host"`
	Port     int    `config:"port"`
	Username string `config:"username"`
	Password string `config:"password"`
}

// engineQueueConfig and engineWorkerConfig mirror the worker section, whose defaults
// legitimately move to driver_asynq_worker in P3; the repository file declares them
// explicitly, so parity is unaffected.
type engineQueueConfig struct {
	Name     string `config:"name"`
	Priority int    `config:"priority"`
}

type engineWorkerConfig struct {
	Concurrency    int                 `config:"concurrency" env:"WORKER_CONCURRENCY"`
	StrictPriority bool                `config:"strict_priority" env:"WORKER_STRICT_PRIORITY"`
	Queues         []engineQueueConfig `config:"queues"`
}

func repositoryConfig(t *testing.T) string {
	t.Helper()

	path := filepath.Join("..", "..", "config.yaml")
	if _, statErr := os.Stat(path); statErr != nil {
		t.Skip("repository root config.yaml is unavailable")
	}
	return path
}

// flatten exports a struct into dotted leaf paths rendered as text. Both sides of the
// parity assertion use distinct Go types for the same shape, so values are compared
// textually instead of handing cmp a cross-type diff.
func flatten(prefix string, v reflect.Value, out map[string]string) {
	t := v.Type()

	for i := 0; i < t.NumField(); i++ {
		field := t.Field(i)
		if field.PkgPath != "" {
			continue
		}

		fv := v.Field(i)
		path := prefix + "." + field.Name
		if fv.Kind() == reflect.Struct && fv.Type() != durationType {
			flatten(path, fv, out)
			continue
		}
		out[path] = fmt.Sprint(fv.Interface())
	}
}

// durationType mirrors the engine's own notion of a scalar duration field.
var durationType = reflect.TypeFor[time.Duration]()

func TestEngineParityWithLegacyLoader(t *testing.T) {
	path := repositoryConfig(t)

	scenarios := []struct {
		name string
		env  map[string]string
	}{
		{name: "file only", env: nil},
		{
			name: "implicit enable from hosts",
			env: map[string]string{
				"DB_HOST": "postgres", "REDIS_ADDR": "redis:6379", "CLICKHOUSE_HOST": "ch:9000",
			},
		},
		{
			name: "explicit flags win over implicit enable",
			env: map[string]string{
				"DB_HOST": "postgres", "DB_ENABLED": "false",
				"REDIS_ADDR": "redis:6379", "REDIS_ENABLED": "false",
				"CLICKHOUSE_HOST": "ch:9000", "CLICKHOUSE_ENABLED": "false",
			},
		},
		{
			name: "scalar overrides and duration parsing",
			env: map[string]string{
				"LOG_LEVEL": "debug", "APP_ADDR": ":9999", "DB_SLOW_THRESHOLD": "1s",
				"REDIS_MAINT_NOTIFICATIONS": "true", "OTEL_SAMPLING_RATE": "0.5",
			},
		},
	}

	for _, scenario := range scenarios {
		t.Run(scenario.name, func(t *testing.T) {
			for name, value := range scenario.env {
				t.Setenv(name, value)
			}

			legacy := load(path, false)

			src := newYAMLSource(t, path)

			engine := extpoints.NewConfigRegistry(src)
			require.NoError(t, engine.Declare("parity",
				extpoints.ConfigBinding{Prefix: "app", Target: &engineAppConfig{}},
				extpoints.ConfigBinding{Prefix: "database", Target: &engineDatabaseConfig{}},
				extpoints.ConfigBinding{Prefix: "redis", Target: &engineRedisConfig{}},
				extpoints.ConfigBinding{Prefix: "clickhouse", Target: &engineClickHouseConfig{}},
				extpoints.ConfigBinding{Prefix: "log", Target: &engineLogConfig{}},
				extpoints.ConfigBinding{Prefix: "otel", Target: &engineOtelConfig{}},
				extpoints.ConfigBinding{Prefix: "worker", Target: &engineWorkerConfig{}},
			))
			require.NoError(t, engine.Resolve())

			var app engineAppConfig
			var database engineDatabaseConfig
			var redis engineRedisConfig
			var clickhouse engineClickHouseConfig
			var log engineLogConfig
			var otel engineOtelConfig
			var worker engineWorkerConfig
			for _, binding := range []struct {
				prefix string
				target any
			}{
				{"app", &app}, {"database", &database}, {"redis", &redis},
				{"clickhouse", &clickhouse}, {"log", &log}, {"otel", &otel}, {"worker", &worker},
			} {
				require.NoError(t, engine.Bind(binding.prefix, binding.target))
			}

			// The legacy loader keeps two code-level defaults outside its tags; the engine
			// expresses them as declared defaults, so normalise before diffing (spec C1).
			if legacy.App.SessionAge <= 0 {
				legacy.App.SessionAge = 86400
			}
			if legacy.Otel.TracerName == "" {
				legacy.Otel.TracerName = "github.com/Rain-kl/Wavelet"
			}

			legacyFlat := map[string]string{}
			flatten("app", reflect.ValueOf(legacy.App), legacyFlat)
			flatten("database", reflect.ValueOf(legacy.Database), legacyFlat)
			flatten("redis", reflect.ValueOf(legacy.Redis), legacyFlat)
			flatten("clickhouse", reflect.ValueOf(legacy.ClickHouse), legacyFlat)
			flatten("log", reflect.ValueOf(legacy.Log), legacyFlat)
			flatten("otel", reflect.ValueOf(legacy.Otel), legacyFlat)
			flatten("worker", reflect.ValueOf(legacy.Worker), legacyFlat)

			engineFlat := map[string]string{}
			flatten("app", reflect.ValueOf(app), engineFlat)
			flatten("database", reflect.ValueOf(database), engineFlat)
			flatten("redis", reflect.ValueOf(redis), engineFlat)
			flatten("clickhouse", reflect.ValueOf(clickhouse), engineFlat)
			flatten("log", reflect.ValueOf(log), engineFlat)
			flatten("otel", reflect.ValueOf(otel), engineFlat)
			flatten("worker", reflect.ValueOf(worker), engineFlat)

			assert.Empty(t, cmp.Diff(legacyFlat, engineFlat), "engine resolution drifted from legacy loader")
		})
	}
}

测试文件的 import 块必须包含:fmt、os、path/filepath、reflect、testing、time、github.com/google/go-cmp/cmp、github.com/spf13/viper、github.com/stretchr/testify/assert、github.com/stretchr/testify/require、Wavelet/core/extpoints。

  • Step 4: 运行对拍确认等价

Run: cd backend && go test ./pkg/config/ -run TestEngineParityWithLegacyLoader -v Expected: 四个场景全部 --- PASS,无任何 drifted 断言输出。若出现 drift,逐项核对是否为 spec §4.3 登记的 C1–C5 有意差异:是则在该场景补注释说明差异来源,否则按 drift 修复引擎。

  • Step 5: 确认既有测试与构建仍全绿

Run: cd backend && go test ./... && go build -o /dev/null ./... Expected: 全绿。

  • Step 6: 提交
git add backend/pkg/config/
git commit -m "refactor(config): make legacy loader reentrant and add engine parity test"

Task 9: 架构门禁脚本禁止 core 引入 viper

Files:

  • Modify: scripts/check_cordis_architecture.sh:57-65

  • Step 1: 扩展检查项

把 CORE_FRAMEWORK_IMPORTS 的匹配式加入 viper、mapstructure 与 gin 的兄弟框架,使配置装载依赖无法渗进内核:

CORE_FRAMEWORK_IMPORTS=$(rg -n '"github.com/gin-gonic/gin"|"gorm.io/gorm"|"github.com/hibiken/asynq"|"github.com/robfig/cron|"github.com/spf13/viper"|"github.com/mitchellh/mapstructure"' \
    "${BACKEND_DIR}/core/" --glob '*.go' -g '!*contracts*' -g '!*_test.go' || true)

同步更新失败提示文案,列出新增的两个包:

    log_fail "backend/core/ 严禁导入具体 Web/ORM/Worker/Config 运行时框架 (gin, gorm, asynq, cron, viper, mapstructure):"
  • Step 2: 运行脚本确认通过

Run: ./scripts/check_cordis_architecture.sh Expected: ✓ backend/core/ 无重型框架依赖,末行 ✓ 所有 Cordis 架构合规性检查全部通过 (0 Violations)!,退出码 0。

  • Step 3: 反向验证检查有效性

Run: printf 'package core\n\nimport _ "github.com/spf13/viper"\n' > backend/core/zz_probe_tmp.go && ./scripts/check_cordis_architecture.sh; STATUS=$?; rm backend/core/zz_probe_tmp.go; exit $STATUS Expected: 报 ✗ [FAIL] backend/core/ 严禁导入具体 Web/ORM/Worker/Config 运行时框架,退出码非 0。(临时文件必须删除,用后确认 git status --short 干净。)

  • Step 4: 提交
git add scripts/check_cordis_architecture.sh
git commit -m "chore(arch): forbid viper and mapstructure inside the micro-kernel"

Task 10: 收尾验证与文档同步

Files:

  • Modify: AGENTS.md(Cordis 分层清单补配置扩展点条目)

  • Modify: docs/WAVELET_WHITE_PAPER.md(§5.1 顶级分包说明)

  • Step 1: 写内核侧用法文档

在 AGENTS.md 的“严格遵循事项 (Guardrails)”中,扩展点自包含注册 列表内 动态配置 一条之后补充:

  - **静态配置声明**:插件在 `Apply` 中通过 `ctx.Config().Bind("<prefix>", &cfg)` 读取自己声明的配置(字段用 `config` / `env` / `default` / `autoEnable` / `secret` tag 声明);需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明。**严禁**新增全局配置单例或在 `backend/pkg/` 读取配置。
  • Step 2: 修正白皮书漂移

在 docs/WAVELET_WHITE_PAPER.md §5.1 的顶级分包说明后追加一条:

- **配置所有权下沉**:`backend/pkg/config` 全局单例已废除。`core/extpoints` 只提供配置声明与解析引擎(不 import viper),`plugins/infra/config` 承担文件与环境装载,读哪些字段由各插件自行声明;组合根不再跨插件判断配置选实现,改由 `ConfigGatedPlugin` 门禁 + `FiberSkipped` 决定激活方。
  • Step 3: 全量门禁

Run: make code-check Expected: 架构脚本 0 Violations;golangci-lint run 无输出;前端 tsc 与 eslint 无新增错误(本计划未触碰前端,若报既有错误需确认为基线问题)。

  • Step 4: 格式化

Run: make format Expected: gofumpt 无 diff 或自动格式化;git status --short 中出现的文件需一并纳入下一步。

  • Step 5: 实跑确认应用行为未变

Run: cd backend && go run main.go all 2>&1 | head -40 Expected: banner 正常输出、迁移日志正常、[Config] loaded configuration 出现,进程正常启动后 Ctrl-C 优雅退出。这是 P1/P2 的验收线:新能力已就位,生产路径仍走旧单例,行为必须与改动前一致。

  • Step 6: 提交
git add AGENTS.md docs/WAVELET_WHITE_PAPER.md
git commit -m "docs(config): record the configuration extension point ownership rules"

完成标准(本计划)

  1. cd backend && go test ./... 与 go build ./... 全绿;make code-check 与 ./scripts/check_cordis_architecture.sh 零违规。
  2. 引擎单测覆盖:优先级四档、autoEnable 与显式 env 的相对优先、标量 env→切片、time.Duration、结构体切片、冲突校验、脱敏导出、无 source 时的错误路径。
  3. TestEngineParityWithLegacyLoader 四个场景零 drift,证明除 spec §4.3 的 C1–C5 外解析结果与旧实现逐 key 等价。
  4. 同时挂载门禁谓词相反的两个插件时,恰好一个 FiberActive、一个 FiberSkipped,且被跳过者 Apply 从未执行。
  5. core/ 不出现 viper/mapstructure import,且架构脚本能主动拦截该违规。
  6. 生产启动路径行为未变(仍由 config.Config 供值),业务插件与 cmd 零改动。

与 spec 的偏差(实施时须回写 spec)

计划编写阶段的自审发现三处与本 spec 已批准版本不一致的实现,均属实现期发现的正确性问题。在 Task 10 落库时一并回写 docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md:

# spec 原述 本计划实现 理由
S1 §4.3 C1 与 §6 把 app.session_age<=0 的判定列为内核解析错误(ErrConfigInvalid) 引擎不做值域校验;ErrConfigInvalid 保留为源级校验占位,值域由声明者在 Bind 之后自行校验(auth 校验 SessionAge > 0 并使 Apply 失败) 引擎被设计成不认识任何业务 key 的语义,让它知道"session_age 必须为正"会破坏该不变式并把业务规则塞进内核
S2 §3.2 ConfigView 含 Source(key) string 更名 Origin(key),并新增 Value(key) (any, bool);ConfigExtension 新增 SetSource(src) 与 Resolved() Source 与类型名 ConfigSource 在同文件内易混淆;Value 是 core.ConfigGet[T] 的支撑(Go 方法不能带类型参数);SetSource 进接口以避免 WithConfigSource 里的运行时类型断言
S3 §3.5 只给出 WithShutdownTimeout 与 app.Prepare() 额外新增 App.ShutdownTimeout() 读取器与 SetShutdownTimeout(d) *App(链式,与既有 WithProfile 风格一致) 组合根需要在 Prepare() 之后把已解析的预算写回内核,原先只有构造期选项,无法表达该顺序

回写时同步修正 §7.3 的分期编号:本计划覆盖 P1 + P2,P3 + P4 由后续计划承接(pkg/idgen 解耦、27 个文件迁移、删除 backend/pkg/config)。