chore: archive v1.1 phase directories to milestones

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 22:06:36 +01:00
co-authored by Claude Opus 4.6
parent 175ff96404
commit 6610366c2f
27 changed files with 0 additions and 0 deletions
@@ -0,0 +1,302 @@
---
phase: 07-custom-rules-and-print-config
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
- config/config.go
- config/config_test.go
autonomous: true
requirements:
- RULE-01
- RULE-02
- RULE-03
must_haves:
truths:
- "TOML [[rules]] blocks parse into classify.Rule slices"
- "Missing protocol or class in a rule produces a clear error at startup"
- "User rules are returned separately from FreqCfgs for caller to prepend"
- "New class names without explicit sound config get auto-assigned frequencies in 1200-2400 Hz range"
- "Built-in class names in user rules do not get overwritten by auto-freq"
artifacts:
- path: "config/config.go"
provides: "RawRule, LoadResult, validateRules, convertRules, autoAssignFreq, addAutoFreqEntries"
contains: "type LoadResult struct"
- path: "config/config_test.go"
provides: "Tests for rule parsing, validation, auto-freq, LoadResult"
contains: "TestLoadCustomRules"
key_links:
- from: "config/config.go"
to: "classify/rules.go"
via: "convertRules produces []classify.Rule"
pattern: "classify\\.Rule"
- from: "config/config.go"
to: "synth/config.go"
via: "autoAssignFreq creates FreqConfig entries with WaveformPresetHarmonics"
pattern: "synth\\.WaveformPresetHarmonics"
---
<objective>
Extend the config package to parse `[[rules]]` TOML blocks into classification rules, validate them, auto-assign frequencies for new class names, and return a `LoadResult` struct from `Load()`.
Purpose: This is the data layer for user-defined classification rules (RULE-01, RULE-02, RULE-03). The LoadResult struct becomes the contract consumed by Plan 02 for CLI wiring and print-config.
Output: Updated `config/config.go` with RawRule, LoadResult, validation, auto-freq; comprehensive tests in `config/config_test.go`.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-custom-rules-and-print-config/07-CONTEXT.md
@.planning/phases/07-custom-rules-and-print-config/07-RESEARCH.md
@config/config.go
@config/config_test.go
@classify/rules.go
@classify/types.go
@synth/config.go
<interfaces>
<!-- Key types and contracts the executor needs. -->
From classify/rules.go:
```go
type Rule struct {
Protocol string
DstPort uint16
Class TrafficClass
}
var DefaultRules = []Rule{ ... } // 12 rules, first-match-wins
```
From classify/types.go:
```go
type TrafficClass string
func AllClasses() []TrafficClass // returns 14 built-in classes
```
From synth/config.go:
```go
type WaveformType int
const WaveformSine WaveformType = 1
type FreqConfig struct {
BaseHz float64
Harmonics []HarmonicDef
Pan float64
WaveformType WaveformType
}
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ ... } // 14 entries, max 1047 Hz
func WaveformPresetHarmonics(wt WaveformType, baseHz float64, sampleRate int) []HarmonicDef
const SampleRate = 44100
```
From config/config.go (current):
```go
type SoundOverride struct {
Frequency *float64 `toml:"frequency"`
Waveform *string `toml:"waveform"`
}
type rawConfig struct {
Sounds map[string]SoundOverride `toml:"sounds"`
}
func Load(configPath string) (map[classify.TrafficClass]synth.FreqConfig, error)
```
From config/config_test.go (patterns):
```go
func writeTOML(t *testing.T, content string) string // creates temp TOML file
// Tests call config.Load(path) and check returned map
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: RawRule, LoadResult, validation, conversion, and auto-freq with TDD</name>
<files>config/config.go, config/config_test.go</files>
<read_first>config/config.go, config/config_test.go, classify/rules.go, classify/types.go, synth/config.go</read_first>
<behavior>
- TestLoadCustomRules: TOML with `[[rules]]` block (port=8080, protocol="tcp", class="MyApp") + `[sounds.MyApp]` (frequency=300.0) parses successfully; LoadResult.UserRules has len 1 with Protocol="tcp", DstPort=8080, Class="MyApp"; LoadResult.FreqCfgs["MyApp"].BaseHz == 300.0
- TestLoadCustomRuleNoPort: TOML with `[[rules]]` (protocol="udp", class="AllUDP", no port field) parses; UserRules[0].DstPort == 0
- TestLoadCustomRuleMissingProtocol: TOML `[[rules]]` with class="X" but no protocol field -> error containing "protocol is required"
- TestLoadCustomRuleMissingClass: TOML `[[rules]]` with protocol="tcp" but no class field -> error containing "class is required"
- TestLoadCustomRuleInvalidProtocol: TOML `[[rules]]` with protocol="ftp" -> error containing "invalid protocol"
- TestLoadCustomRuleUnknownField: TOML `[[rules]]` with typo_field="bad" -> error containing "typo_field" (from Undecoded())
- TestUserRulesPrepend: Load returns UserRules separately from FreqCfgs; caller can do `append(result.UserRules, classify.DefaultRules...)` to get user rules first
- TestAutoFreqAssignment: TOML with `[[rules]]` (class="GameServer", protocol="tcp") and NO `[sounds.GameServer]` -> FreqCfgs contains "GameServer" entry with BaseHz in range [1200, 2350] and WaveformType == WaveformSine
- TestAutoFreqDeterministic: Two Load() calls with same class name produce same BaseHz
- TestAutoFreqSkipsBuiltins: TOML with `[[rules]]` (class="HTTPS", protocol="tcp", port=443) -> FreqCfgs["HTTPS"].BaseHz == 175.0 (the default), NOT an auto-assigned value
- TestLoadResultConfigPath: Load(explicit_path) -> LoadResult.ConfigPath == explicit_path; Load("") with no file -> LoadResult.ConfigPath == ""
- TestLoadNoConfigReturnsLoadResult: Load("") in empty dir returns LoadResult with len(FreqCfgs)==14, len(UserRules)==0, ConfigPath==""
- TestExistingTestsStillPass: All 8 existing tests in config_test.go continue to pass after Load() signature change (they need updating to use LoadResult)
</behavior>
<action>
RED phase -- Write all test functions listed in behavior above in config/config_test.go. Tests call config.Load() and assert on LoadResult fields. Update the 8 existing tests to use the new LoadResult return type (e.g., `result, err := config.Load(path); cfgs := result.FreqCfgs`). Run tests -- they must all fail (Load still returns bare map).
GREEN phase -- Modify config/config.go:
1. Add RawRule struct (per D-01):
```go
type RawRule struct {
Port *uint16 `toml:"port"`
Protocol string `toml:"protocol"`
Class string `toml:"class"`
}
```
2. Add Rules field to rawConfig:
```go
type rawConfig struct {
Sounds map[string]SoundOverride `toml:"sounds"`
Rules []RawRule `toml:"rules"`
}
```
3. Add LoadResult struct (per D-13, Claude's Discretion: struct over tuple):
```go
type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule
ConfigPath string
}
```
4. Add validateRules function called from validate():
```go
func validateRules(rules []RawRule) error {
validProtocols := map[string]bool{"tcp": true, "udp": true, "icmp": true}
for i, r := range rules {
if r.Protocol == "" {
return fmt.Errorf("config: rules[%d]: protocol is required", i)
}
if !validProtocols[r.Protocol] {
return fmt.Errorf("config: rules[%d]: invalid protocol %q -- valid: tcp, udp, icmp", i, r.Protocol)
}
if r.Class == "" {
return fmt.Errorf("config: rules[%d]: class is required", i)
}
}
return nil
}
```
5. Add convertRules function:
```go
func convertRules(raw []RawRule) []classify.Rule {
result := make([]classify.Rule, len(raw))
for i, r := range raw {
var port uint16
if r.Port != nil {
port = *r.Port
}
result[i] = classify.Rule{
Protocol: r.Protocol,
DstPort: port,
Class: classify.TrafficClass(r.Class),
}
}
return result
}
```
6. Add autoAssignFreq function (per D-07/D-08, using FNV-32a):
```go
import "hash/fnv"
func autoAssignFreq(className string) float64 {
h := fnv.New32a()
h.Write([]byte(className))
const (
baseHz = 1200.0
stepHz = 50.0
numSteps = uint32(24)
)
return baseHz + float64(h.Sum32()%numSteps)*stepHz
}
```
7. Add addAutoFreqEntries function (called AFTER merge, per Pitfall 4):
```go
func addAutoFreqEntries(cfgs map[classify.TrafficClass]synth.FreqConfig, userRules []classify.Rule) {
for _, rule := range userRules {
if _, exists := cfgs[rule.Class]; !exists {
baseHz := autoAssignFreq(string(rule.Class))
cfgs[rule.Class] = synth.FreqConfig{
BaseHz: baseHz,
WaveformType: synth.WaveformSine,
Harmonics: synth.WaveformPresetHarmonics(synth.WaveformSine, baseHz, synth.SampleRate),
Pan: 0.0,
}
}
}
}
```
8. Change Load() signature to return LoadResult:
```go
func Load(configPath string) (LoadResult, error) {
```
- When no config found: return `LoadResult{FreqCfgs: copyDefaults(), ConfigPath: ""}`, nil
- After parseFile + validate + merge: call convertRules, call addAutoFreqEntries, return LoadResult with FreqCfgs, UserRules, and the resolved config path
- Update validate() to also call validateRules(raw.Rules)
- The `merge()` function for unknown class names should NO LONGER print a warning for classes that appear in raw.Rules -- those are legitimate custom classes. Keep the warning only for [sounds.X] where X is neither a built-in class nor a class defined in [[rules]].
9. Update the merge() function: Change the unknown-class warning logic. Instead of always warning on unknown class names in sounds, accept the `userRules []RawRule` as a parameter (or check after conversion). Simplest: after converting rules, pass the set of user-defined class names to merge so it can skip the warning for those. Alternatively, run merge first (with warnings), then let addAutoFreqEntries handle user-defined classes. The warning is acceptable for now -- it only fires for [sounds.X] where X has no matching [[rules]] entry AND is not a built-in class. Keep existing warning behavior, it is harmless.
REFACTOR phase -- Clean up if needed. Ensure all tests pass.
Run `go test ./config/...` -- all tests must pass.
Run `go test ./...` -- full suite must pass (the Load() call site in main.go will break; that is expected and fixed in Plan 02).
</action>
<verify>
<automated>go test ./config/... -v -count=1</automated>
</verify>
<acceptance_criteria>
- config/config.go contains `type RawRule struct` with `Port *uint16`, `Protocol string`, `Class string` fields
- config/config.go contains `type LoadResult struct` with `FreqCfgs`, `UserRules`, `ConfigPath` fields
- config/config.go contains `func Load(configPath string) (LoadResult, error)`
- config/config.go contains `func validateRules(rules []RawRule) error`
- config/config.go contains `func convertRules(raw []RawRule) []classify.Rule`
- config/config.go contains `func autoAssignFreq(className string) float64` with `fnv.New32a()`
- config/config.go contains `func addAutoFreqEntries(`
- config/config.go imports `"hash/fnv"`
- config/config_test.go contains `TestLoadCustomRules`
- config/config_test.go contains `TestAutoFreqAssignment`
- config/config_test.go contains `TestAutoFreqSkipsBuiltins`
- config/config_test.go contains `TestLoadCustomRuleMissingProtocol`
- `go test ./config/... -count=1` exits 0
</acceptance_criteria>
<done>
Load() returns LoadResult with FreqCfgs + UserRules + ConfigPath. TOML [[rules]] blocks parse, validate (protocol required, class required, valid protocols only), and convert to classify.Rule slices. Auto-frequency assignment creates FreqConfig entries for new class names in 1200-2350 Hz range using FNV-32a. Built-in class names from user rules are NOT overwritten. All existing config tests updated and passing. Full config test suite green.
</done>
</task>
</tasks>
<verification>
- `go test ./config/... -v -count=1` passes all tests including new rule-related tests
- `go vet ./config/...` reports no issues
- LoadResult struct is exported and usable from cmd/netsynth package
</verification>
<success_criteria>
- config.Load() returns LoadResult struct (not bare map)
- [[rules]] TOML blocks parse into UserRules field
- Validation catches missing protocol, missing class, invalid protocol
- Auto-freq assigns 1200-2350 Hz for new class names, skips built-ins
- All 8 existing config tests updated and passing
- All new tests passing
</success_criteria>
<output>
After completion, create `.planning/phases/07-custom-rules-and-print-config/07-01-SUMMARY.md`
</output>
@@ -0,0 +1,94 @@
---
phase: 07-custom-rules-and-print-config
plan: "01"
subsystem: config
tags: [config, rules, tdd, classification, auto-freq]
dependency_graph:
requires: []
provides: [LoadResult, RawRule, validateRules, convertRules, autoAssignFreq, addAutoFreqEntries]
affects: [cmd/netsynth/main.go]
tech_stack:
added: ["hash/fnv"]
patterns: [LoadResult-struct, FNV-32a-deterministic-hash, TDD-red-green]
key_files:
created: []
modified:
- config/config.go
- config/config_test.go
- cmd/netsynth/main.go
decisions:
- "addAutoFreqEntries runs before merge so [sounds.X] overrides apply to user-defined classes"
- "merge() warning for unknown class names still fires for [sounds.X] where X is neither built-in nor in [[rules]] -- acceptable harmless warning"
- "main.go call site updated to use LoadResult.FreqCfgs -- minimal fix to keep compile; full wiring deferred to Plan 02"
metrics:
duration: 3min
completed: "2026-03-26T20:45:08Z"
tasks_completed: 1
files_modified: 3
---
# Phase 7 Plan 01: Config Rule Parsing and LoadResult Summary
Extend config package to parse `[[rules]]` TOML blocks, validate them, auto-assign frequencies for new class names using FNV-32a, and return a `LoadResult` struct from `Load()`.
## What Was Built
`config.Load()` now returns `LoadResult{FreqCfgs, UserRules, ConfigPath}` instead of a bare map. The new struct is the data contract for Plan 02's CLI wiring and `--print-config` output.
**New types and functions in config/config.go:**
- `RawRule` struct: `Port *uint16`, `Protocol string`, `Class string` — pointer Port to distinguish missing vs zero
- `LoadResult` struct: `FreqCfgs`, `UserRules []classify.Rule`, `ConfigPath string`
- `validateRules()`: checks protocol required, class required, valid protocols (tcp/udp/icmp)
- `convertRules()`: converts `[]RawRule` to `[]classify.Rule`
- `autoAssignFreq()`: FNV-32a hash → deterministic Hz in [1200, 2350] range (24 steps of 50Hz)
- `addAutoFreqEntries()`: creates `FreqConfig` entries for new class names, skips built-ins
- Import: `hash/fnv`
**Key operation order:** `addAutoFreqEntries` runs before `merge` so that `[sounds.MyApp]` sound overrides apply to user-defined classes that were added by auto-freq.
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | RawRule, LoadResult, validation, conversion, auto-freq with TDD | 4b365cd | config/config.go, config/config_test.go, cmd/netsynth/main.go |
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Operation order: addAutoFreqEntries must run before merge**
- **Found during:** Task 1 GREEN phase
- **Issue:** Plan's action section said "merge first, then addAutoFreqEntries" but this caused [sounds.MyApp] overrides to be ignored for user-defined classes (merge only applies to classes already in the map)
- **Fix:** Reversed the order — addAutoFreqEntries first (creates the entry), then merge (applies sound overrides)
- **Files modified:** config/config.go
- **Commit:** 4b365cd
**2. [Rule 3 - Blocking] main.go call site updated to use LoadResult**
- **Found during:** Task 1 GREEN phase
- **Issue:** Plan notes this break is expected but tests wouldn't compile without it
- **Fix:** Minimal one-line update: `loadResult, err := config.Load(...)` + `freqCfgs := loadResult.FreqCfgs`
- **Files modified:** cmd/netsynth/main.go
- **Commit:** 4b365cd
## Test Coverage
21 tests total (8 existing + 13 new):
- `TestLoadCustomRules` - TOML [[rules]] block with port/protocol/class
- `TestLoadCustomRuleNoPort` - optional port field, DstPort=0 when absent
- `TestLoadCustomRuleMissingProtocol` - validation error "protocol is required"
- `TestLoadCustomRuleMissingClass` - validation error "class is required"
- `TestLoadCustomRuleInvalidProtocol` - validation error "invalid protocol"
- `TestLoadCustomRuleUnknownField` - undecoded TOML field error
- `TestUserRulesPrepend` - UserRules field usable for prepend pattern
- `TestAutoFreqAssignment` - BaseHz in [1200, 2350], WaveformSine
- `TestAutoFreqDeterministic` - same class name produces same Hz
- `TestAutoFreqSkipsBuiltins` - HTTPS stays at 175.0 default
- `TestLoadResultConfigPath` - ConfigPath populated correctly
- `TestLoadNoConfigReturnsLoadResult` - returns LoadResult with empty UserRules
- All 8 existing tests updated to use `result.FreqCfgs`
## Known Stubs
None. All new functions are fully implemented and tested.
## Self-Check: PASSED
@@ -0,0 +1,444 @@
---
phase: 07-custom-rules-and-print-config
plan: 02
type: execute
wave: 2
depends_on:
- "07-01"
files_modified:
- cmd/netsynth/main.go
- cmd/netsynth/main_test.go
- config/config.go
- config/config_test.go
autonomous: true
requirements:
- RULE-02
- CFG-06
must_haves:
truths:
- "User runs netsynth --print-config and sees full effective config as commented TOML on stdout without capture starting"
- "User rules prepend before built-in rules so first-match-wins gives user priority"
- "Print-config output shows source path when config file loaded"
- "Print-config output annotates defaults vs overrides vs auto-assigned"
- "Print-config works without -i flag"
- "Print-config includes [[rules]] section when user rules are present"
artifacts:
- path: "cmd/netsynth/main.go"
provides: "--print-config flag, runPrintConfig(), user rule prepend"
contains: "print-config"
- path: "config/config.go"
provides: "PrintConfig function"
contains: "func PrintConfig("
- path: "cmd/netsynth/main_test.go"
provides: "Tests for --print-config flag"
contains: "TestPrintConfigFlagRegistered"
- path: "config/config_test.go"
provides: "Tests for PrintConfig output"
contains: "TestPrintConfigContainsAllClasses"
key_links:
- from: "cmd/netsynth/main.go"
to: "config/config.go"
via: "runPrintConfig calls config.Load then config.PrintConfig"
pattern: "config\\.PrintConfig"
- from: "cmd/netsynth/main.go"
to: "classify/rules.go"
via: "append(result.UserRules, classify.DefaultRules...)"
pattern: "append.*UserRules.*DefaultRules"
---
<objective>
Wire the LoadResult into main.go (user rules prepend, --print-config flag), implement the PrintConfig output function, and add comprehensive tests for both.
Purpose: Completes RULE-02 (user rules fire before built-ins at the CLI level) and CFG-06 (--print-config UX). This is the final plan for Phase 7 and the v1.1 milestone.
Output: Updated main.go with --print-config and user rule prepending; PrintConfig function in config package; tests in both test files.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-custom-rules-and-print-config/07-CONTEXT.md
@.planning/phases/07-custom-rules-and-print-config/07-RESEARCH.md
@.planning/phases/07-custom-rules-and-print-config/07-01-SUMMARY.md
@cmd/netsynth/main.go
@cmd/netsynth/main_test.go
@config/config.go
@config/config_test.go
@classify/rules.go
@classify/types.go
@synth/config.go
<interfaces>
<!-- Post-Plan-01 interfaces the executor needs -->
From config/config.go (after Plan 01):
```go
type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule
ConfigPath string
}
func Load(configPath string) (LoadResult, error)
```
From classify/rules.go:
```go
var DefaultRules = []Rule{ ... } // 12 rules
func NewClassifier(rules []Rule) *Classifier
```
From synth/config.go:
```go
var ClassFreqConfigs = map[classify.TrafficClass]FreqConfig{ ... } // 14 entries
type FreqConfig struct {
BaseHz float64
Harmonics []HarmonicDef
Pan float64
WaveformType WaveformType
}
```
From classify/types.go:
```go
func AllClasses() []TrafficClass // 14 built-in classes in display order
```
From cmd/netsynth/main.go (current run() flow):
```go
// listIfaces check is first early-exit
// then mutual exclusion check for --read and -i
// then interface-required check
// then BPF filter validation
// then config.Load(configPath)
// then output path resolution
// then runLiveMode or runPcapMode
```
From cmd/netsynth/main_test.go (patterns):
```go
func newTestCmd() *cobra.Command // creates fresh command with all flags
// Tests use rootCmd.SetArgs, rootCmd.Execute(), check err
// PersistentPreRunE wires test vars to package-level vars
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Wire LoadResult into main.go and add --print-config flag</name>
<files>cmd/netsynth/main.go, cmd/netsynth/main_test.go</files>
<read_first>cmd/netsynth/main.go, cmd/netsynth/main_test.go, config/config.go, classify/rules.go</read_first>
<action>
**main.go changes:**
1. Add package-level var for print-config flag:
```go
var printConfig bool // add alongside existing configPath var
```
2. Register --print-config flag in main() alongside existing flags:
```go
rootCmd.Flags().BoolVar(&printConfig, "print-config", false, "Print effective config as commented TOML and exit")
```
3. In run(), add --print-config check as the SECOND early-exit (after listIfaces, BEFORE the mutual exclusion check). Per Pitfall 2 from research, this must come before the interface-required validation so `netsynth --print-config` works without `-i`:
```go
// --print-config mode (CFG-06, D-09/D-10)
if printConfig {
return runPrintConfig()
}
```
4. Add runPrintConfig function:
```go
func runPrintConfig() error {
result, err := config.Load(configPath)
if err != nil {
return err
}
output := config.PrintConfig(result)
fmt.Print(output)
return nil
}
```
5. Update ALL Load() call sites to use LoadResult (there is one in run()):
```go
// Load config (CFG-01 through CFG-05, D-11: fail fast before capture)
result, err := config.Load(configPath)
if err != nil {
return err
}
```
6. After config load, prepend user rules before creating classifier (per D-04, RULE-02). Update BOTH runLiveMode and runPcapMode. Change their signatures to accept LoadResult instead of bare map:
```go
func runLiveMode(cmd *cobra.Command, result config.LoadResult) error {
// ...
// D-04: user rules prepend before built-ins; first-match-wins
allRules := append(result.UserRules, classify.DefaultRules...)
classifier := classify.NewClassifier(allRules)
// ...use result.FreqCfgs where freqCfgs was used before...
}
```
Do the same for runPcapMode. Update the call sites in run():
```go
if readPath != "" {
return runPcapMode(cmd, result)
}
return runLiveMode(cmd, result)
```
7. In both runLiveMode and runPcapMode, replace `freqCfgs` parameter usage with `result.FreqCfgs` in the encode.RunSynthesis call:
```go
if err := encode.RunSynthesis(collectedSnapshots, outputPath, result.FreqCfgs); err != nil {
```
**main_test.go changes:**
8. Update newTestCmd() to include --print-config flag and --config flag:
```go
var testPrintConfig bool
var testConfigPath string
// ...in flag registration:
rootCmd.Flags().BoolVar(&testPrintConfig, "print-config", false, "Print effective config")
rootCmd.Flags().StringVar(&testConfigPath, "config", "", "Path to TOML config file")
// ...in PersistentPreRunE:
printConfig = testPrintConfig
configPath = testConfigPath
```
9. Add TestPrintConfigFlagRegistered:
```go
func TestPrintConfigFlagRegistered(t *testing.T) {
rootCmd := newTestCmd()
f := rootCmd.Flags().Lookup("print-config")
if f == nil {
t.Fatal("expected --print-config flag to be registered")
}
}
```
10. Add TestPrintConfigNoInterface -- verifies --print-config works without -i:
```go
func TestPrintConfigNoInterface(t *testing.T) {
// Reset globals
ifaceName = ""
listIfaces = false
printConfig = false
configPath = ""
// ... reset all globals
t.Chdir(t.TempDir()) // no netsynth.toml in temp dir
rootCmd := newTestCmd()
rootCmd.SetArgs([]string{"--print-config"})
var outBuf, errBuf bytes.Buffer
rootCmd.SetOut(&outBuf)
rootCmd.SetErr(&errBuf)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("--print-config should not require -i, got error: %v", err)
}
}
```
11. Add TestPrintConfigWithConfigFile -- verifies print-config loads and displays a user config:
```go
func TestPrintConfigWithConfigFile(t *testing.T) {
// Create temp TOML with an override
dir := t.TempDir()
tomlPath := filepath.Join(dir, "test.toml")
os.WriteFile(tomlPath, []byte("[sounds.ICMP]\nfrequency = 100.0\n"), 0644)
// Reset globals
// ...
rootCmd := newTestCmd()
rootCmd.SetArgs([]string{"--print-config", "--config", tomlPath})
var outBuf, errBuf bytes.Buffer
rootCmd.SetOut(&outBuf)
rootCmd.SetErr(&errBuf)
// Capture stdout by redirecting os.Stdout temporarily, OR
// check that no error occurred (PrintConfig writes to os.Stdout via fmt.Print)
err := rootCmd.Execute()
if err != nil {
t.Fatalf("--print-config with --config should succeed, got: %v", err)
}
}
```
</action>
<verify>
<automated>go test ./cmd/netsynth/... -v -count=1 && go test ./config/... -count=1</automated>
</verify>
<acceptance_criteria>
- cmd/netsynth/main.go contains `var printConfig bool`
- cmd/netsynth/main.go contains `"print-config"` flag registration
- cmd/netsynth/main.go contains `if printConfig {` BEFORE the interface-required check
- cmd/netsynth/main.go contains `func runPrintConfig() error`
- cmd/netsynth/main.go contains `append(result.UserRules, classify.DefaultRules...)`
- cmd/netsynth/main.go contains `func runLiveMode(cmd *cobra.Command, result config.LoadResult)`
- cmd/netsynth/main.go contains `func runPcapMode(cmd *cobra.Command, result config.LoadResult)`
- cmd/netsynth/main_test.go contains `TestPrintConfigFlagRegistered`
- cmd/netsynth/main_test.go contains `TestPrintConfigNoInterface`
- cmd/netsynth/main_test.go contains `"print-config"` in newTestCmd()
- cmd/netsynth/main_test.go contains `"config"` flag in newTestCmd()
- `go test ./cmd/netsynth/... -count=1` exits 0
- `go test ./config/... -count=1` exits 0
</acceptance_criteria>
<done>
--print-config flag registered, checked before interface validation (no -i required). runPrintConfig calls config.Load then config.PrintConfig, prints to stdout, exits. User rules prepended in both runLiveMode and runPcapMode via append(result.UserRules, classify.DefaultRules...). All existing and new tests pass.
</done>
</task>
<task type="auto">
<name>Task 2: Implement PrintConfig output function with comment annotations</name>
<files>config/config.go, config/config_test.go</files>
<read_first>config/config.go, config/config_test.go, synth/config.go, classify/types.go, classify/rules.go</read_first>
<action>
Add the PrintConfig function to config/config.go and tests to config/config_test.go.
**config/config.go additions:**
1. Add an `AutoClasses` field to LoadResult to track which classes were auto-assigned (per Open Question 1 from research):
```go
type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule
ConfigPath string
AutoClasses map[classify.TrafficClass]bool // classes with auto-assigned frequencies
}
```
Update addAutoFreqEntries to populate this map. Also update Load() to initialize the map.
2. Add the PrintConfig function. Use manual string building with fmt.Fprintf to a strings.Builder (per D-09, D-10, D-11). The function signature:
```go
func PrintConfig(result LoadResult) string
```
3. Output format (per Pattern 5 from research):
```
# NetSynth effective configuration
# Config source: <path or "none (using defaults)">
# Generated: <date>
```
Then, if result.UserRules is non-empty, emit the [[rules]] section:
```
# Classification rules (user-defined, prepended before built-in rules)
[[rules]]
port = 8080
protocol = "tcp"
class = "MyApp"
```
For each user rule, emit a `[[rules]]` block. If DstPort == 0, omit the `port` line (per D-02 semantics).
Then emit the [sounds] section. Iterate in a deterministic order: first AllClasses() (14 built-in classes in display order), then any user-defined classes sorted alphabetically. For each class:
```
# <ClassName> -- <BaseHz> Hz (<annotation>)
[sounds.<ClassName>]
frequency = <BaseHz>
waveform = "<waveform_string>"
```
Where annotation is:
- `default` -- class is in synth.ClassFreqConfigs AND FreqCfgs entry matches the default BaseHz and WaveformType
- `override` -- class is in synth.ClassFreqConfigs BUT FreqCfgs entry differs from default (user changed it)
- `auto-assigned` -- class is in result.AutoClasses
4. Add waveformString helper to convert WaveformType back to string:
```go
func waveformString(wt synth.WaveformType) string {
switch wt {
case synth.WaveformSine:
return "sine"
case synth.WaveformSquare:
return "square"
case synth.WaveformSawtooth:
return "sawtooth"
case synth.WaveformTriangle:
return "triangle"
default:
return "custom"
}
}
```
5. For deterministic ordering of user-defined classes (not in AllClasses()), collect them, sort by string value, and append after built-in classes.
**config/config_test.go additions:**
6. TestPrintConfigContainsAllClasses: Create a LoadResult with defaults (no overrides, no user rules). Call PrintConfig. Assert output contains all 14 class names from classify.AllClasses(): "ICMP", "DNS", "HTTPS", "HTTP", "SSH", "SMTP", "NTP", "DHCP", "other-TCP", "other-UDP", "unknown-1", "unknown-2", "unknown-3", "unknown-4".
7. TestPrintConfigSourcePath: Create LoadResult with ConfigPath="/home/user/netsynth.toml". Assert output contains `# Config source: /home/user/netsynth.toml`.
8. TestPrintConfigNoSourcePath: Create LoadResult with ConfigPath="". Assert output contains `# Config source: none`.
9. TestPrintConfigContainsRules: Create LoadResult with UserRules containing one rule (Protocol="tcp", DstPort=8080, Class="MyApp"). Assert output contains `[[rules]]`, `port = 8080`, `protocol = "tcp"`, `class = "MyApp"`.
10. TestPrintConfigRuleNoPort: Create LoadResult with UserRules containing rule with DstPort=0. Assert output does NOT contain `port =` for that rule.
11. TestPrintConfigDefaultAnnotation: Create LoadResult with defaults. Assert output contains `(default)` annotation for ICMP entry.
12. TestPrintConfigOverrideAnnotation: Create LoadResult where ICMP has BaseHz=100.0 (differs from default 65.0). Assert output contains `(override)` for ICMP.
13. TestPrintConfigAutoAssignedAnnotation: Create LoadResult with AutoClasses map containing "GameServer"=true. Assert output contains `(auto-assigned)` for GameServer entry.
</action>
<verify>
<automated>go test ./config/... -v -count=1 -run "PrintConfig" && go test ./... -count=1</automated>
</verify>
<acceptance_criteria>
- config/config.go contains `func PrintConfig(result LoadResult) string`
- config/config.go contains `func waveformString(wt synth.WaveformType) string`
- config/config.go LoadResult struct contains `AutoClasses map[classify.TrafficClass]bool`
- config/config.go PrintConfig output contains `# NetSynth effective configuration`
- config/config.go PrintConfig output contains `# Config source:`
- config/config_test.go contains `TestPrintConfigContainsAllClasses`
- config/config_test.go contains `TestPrintConfigSourcePath`
- config/config_test.go contains `TestPrintConfigContainsRules`
- config/config_test.go contains `TestPrintConfigDefaultAnnotation`
- config/config_test.go contains `TestPrintConfigOverrideAnnotation`
- config/config_test.go contains `TestPrintConfigAutoAssignedAnnotation`
- `go test ./... -count=1` exits 0 (full suite green)
</acceptance_criteria>
<done>
PrintConfig produces commented TOML output with: header (source path, date), [[rules]] section for user rules (port omitted when 0), [sounds.*] section for all classes in deterministic order. Each sound entry annotated as (default), (override), or (auto-assigned). Full test suite green including all existing tests.
</done>
</task>
</tasks>
<verification>
- `go test ./... -count=1` -- full suite green
- `go vet ./...` -- no issues
- `netsynth --print-config` outputs commented TOML to stdout (manual check)
- `netsynth --print-config --config <file>` shows overrides annotated as such
</verification>
<success_criteria>
- --print-config flag works without -i, outputs to stdout, exits without capture
- User rules prepended before DefaultRules in both live and pcap modes
- PrintConfig output contains all 14 built-in classes plus any user-defined classes
- Comment annotations correctly distinguish default / override / auto-assigned
- Source path shown in header when config loaded
- [[rules]] section present in output when user rules exist
- Full go test suite passes
</success_criteria>
<output>
After completion, create `.planning/phases/07-custom-rules-and-print-config/07-02-SUMMARY.md`
</output>
@@ -0,0 +1,95 @@
---
phase: 07-custom-rules-and-print-config
plan: "02"
subsystem: config, cmd/netsynth
tags: [config, cli, print-config, rules, wiring, CFG-06, RULE-02]
dependency_graph:
requires: [LoadResult, UserRules, AutoClasses, PrintConfig]
provides: [--print-config flag, runPrintConfig, user-rule-prepend, PrintConfig-output]
affects: [cmd/netsynth/main.go, config/config.go]
tech_stack:
added: ["sort", "time", "strings.Builder"]
patterns: [LoadResult-propagation, user-rule-prepend, annotated-TOML-output]
key_files:
created: []
modified:
- cmd/netsynth/main.go
- cmd/netsynth/main_test.go
- config/config.go
- config/config_test.go
decisions:
- "--print-config check placed after --list-interfaces but before interface-required validation so it works without -i"
- "AutoClasses map added to LoadResult to track which classes were auto-assigned by FNV-32a"
- "PrintConfig returns a string (not writes to io.Writer) for testability; caller prints to stdout"
- "waveformString returns custom for WaveformCustom (zero value used by hand-tuned built-in classes)"
- "classAnnotation: built-in classes compared on both BaseHz and WaveformType for override detection"
metrics:
duration: 8min
completed: "2026-03-26T20:55:00Z"
tasks_completed: 2
files_modified: 4
---
# Phase 7 Plan 02: CLI Wiring and PrintConfig Output Summary
Wire the LoadResult into main.go (user rules prepend, --print-config flag), implement the PrintConfig output function in the config package, and add comprehensive tests for both. Completes RULE-02 and CFG-06 — the final plan for Phase 7 and the v1.1 milestone.
## What Was Built
**cmd/netsynth/main.go:**
- Added `printConfig bool` var and `--print-config` flag registration
- `runPrintConfig()`: calls `config.Load(configPath)` then `config.PrintConfig(result)`, prints to stdout, exits clean
- --print-config check fires before interface-required validation (no -i needed)
- `runLiveMode` and `runPcapMode` now accept `config.LoadResult` instead of bare `map[TrafficClass]FreqConfig`
- User rules prepend in both modes: `append(result.UserRules, classify.DefaultRules...)` (RULE-02)
- Removed unused `synth` import
**config/config.go:**
- `LoadResult` gains `AutoClasses map[classify.TrafficClass]bool` field
- `addAutoFreqEntries` updated to accept and populate `autoClasses` map
- `Load()` initializes `AutoClasses` map and returns it in `LoadResult`
- `PrintConfig(result LoadResult) string`: generates commented TOML output with:
- Header: `# NetSynth effective configuration`, `# Config source: <path or "none (using defaults)">`, `# Generated: <UTC timestamp>`
- `[[rules]]` section for each user rule (port omitted when DstPort==0)
- `[sounds.*]` section for all classes in deterministic order (14 built-ins in AllClasses() order, then user-defined sorted alphabetically)
- Per-class annotation: `(default)`, `(override)`, or `(auto-assigned)`
- `waveformString()`: converts WaveformType to TOML string
- `classAnnotation()`: determines annotation based on AutoClasses membership and comparison with defaults
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Wire LoadResult into main.go and add --print-config flag | d43914f | cmd/netsynth/main.go, cmd/netsynth/main_test.go |
| 2 | Implement PrintConfig output function with comment annotations | b52e36b | config/config.go, config/config_test.go |
## Deviations from Plan
None - plan executed exactly as written.
## Test Coverage
New tests added (8 PrintConfig tests in config_test.go, 3 print-config tests in main_test.go):
**config/config_test.go:**
- `TestPrintConfigContainsAllClasses` - all 14 class names in output
- `TestPrintConfigSourcePath` - `# Config source: <path>` in header
- `TestPrintConfigNoSourcePath` - `# Config source: none` when no config
- `TestPrintConfigContainsRules` - `[[rules]]` section with port/protocol/class
- `TestPrintConfigRuleNoPort` - port line omitted when DstPort==0
- `TestPrintConfigDefaultAnnotation` - `(default)` for unmodified built-in class
- `TestPrintConfigOverrideAnnotation` - `(override)` for modified built-in class
- `TestPrintConfigAutoAssignedAnnotation` - `(auto-assigned)` for FNV-hash assigned class
**cmd/netsynth/main_test.go:**
- `TestPrintConfigFlagRegistered` - flag exists on command
- `TestPrintConfigNoInterface` - --print-config works without -i
- `TestPrintConfigWithConfigFile` - --print-config with --config succeeds
Full suite: `go test ./... -count=1` all 7 packages pass.
## Known Stubs
None. All functionality is fully implemented and wired.
## Self-Check: PASSED
@@ -0,0 +1,123 @@
# Phase 7: Custom Rules and Print-Config - Context
**Gathered:** 2026-03-26
**Status:** Ready for planning
<domain>
## Phase Boundary
Add user-defined traffic classification rules in TOML (`[[rules]]` array-of-tables) that prepend before built-in rules, with automatic synthesis layer creation for new class names. Add `--print-config` flag that outputs the full effective config as commented TOML to stdout without starting a capture.
Requirements covered: RULE-01, RULE-02, RULE-03, CFG-06.
</domain>
<decisions>
## Implementation Decisions
### Custom Rule TOML Schema
- **D-01:** Custom rules use TOML array-of-tables `[[rules]]` with three fields: `port` (uint16, optional — omit to match any port), `protocol` (string, required — "tcp", "udp", or "icmp"), and `class` (string, required — the TrafficClass name). Sound configuration for the class goes in a separate `[sounds.<class>]` block.
- **D-02:** Port is optional. When omitted (or 0), the rule matches all traffic for the given protocol, mirroring the existing `Rule.DstPort = 0` semantics in `classify.DefaultRules`.
- **D-03:** Protocol is required. No implicit "match both TCP and UDP" behavior. User must write separate rules for each protocol.
### Rule Ordering and Priority
- **D-04:** User-defined rules are prepended before built-in `DefaultRules` (RULE-02). First-match-wins semantics are preserved. A user rule for port 443/tcp fires before the built-in HTTPS rule.
- **D-05:** Rules within the TOML `[[rules]]` array maintain their file order. First rule in the file is first to match.
### Class Name Collision Policy
- **D-06:** User-defined class names that match built-in names (e.g., `class = "HTTPS"`) are treated as overrides, not errors. The user's rule fires first (prepended), so traffic matching it gets classified under the same built-in class name via the user rule. Sound config in `[sounds.HTTPS]` still applies. This resolves the design question flagged in STATE.md.
### Sound Assignment for Custom Classes
- **D-07:** New class names that have no `[sounds.<class>]` entry automatically get sensible defaults: a frequency from an unused range and sine waveform. This satisfies RULE-03 (no silent gaps for user-defined classes).
- **D-08:** (Claude's Discretion) The auto-assignment algorithm — how to pick frequencies for new classes that don't collide with built-in frequencies. Could use a hash of the class name, a sequential pool, or a deterministic spread across an unused frequency band.
### Print-Config
- **D-09:** `--print-config` outputs the full effective config (defaults merged with user overrides and custom rules) as commented TOML. Comments indicate which values are defaults vs overrides. This satisfies CFG-06.
- **D-10:** Output goes to stdout (pipeable). User can do `netsynth --print-config > template.toml` to create a config template. The command exits without starting a capture.
- **D-11:** If a config file is loaded (via auto-discovery or `--config`), show its source path in a header comment.
### Config Package Extension
- **D-12:** The existing `config.Load()` function must be extended to parse `[[rules]]` blocks in addition to `[sounds.*]`. The `rawConfig` struct gains a `Rules []RawRule` field.
- **D-13:** `config.Load()` returns both the merged `FreqConfig` map and the user rules (as `[]classify.Rule`). The caller prepends user rules before `classify.DefaultRules`.
### Claude's Discretion
- How to extend `rawConfig` struct and `Load()` return type (tuple, struct, or new function)
- Auto-frequency assignment algorithm for custom classes without explicit sound config
- Whether `--print-config` is a Cobra subcommand or a flag on the root command
- How to format the commented TOML output (manual string building vs TOML encoder + post-processing)
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Classification System
- `classify/rules.go``Rule` struct (Protocol, DstPort, Class), `DefaultRules` ordered slice, first-match-wins
- `classify/types.go``TrafficClass` string type, `AllClasses()`, `ClassifiedPacket`, `WindowSnapshot`
- `classify/classifier.go``NewClassifier(rules []Rule)` — accepts injected rule slice
### Config System (Phase 6 output)
- `config/config.go``Load()`, `rawConfig`, `SoundOverride`, `merge()`, `validate()`, `parseWaveform()`
- `config/config_test.go` — Existing test patterns for TOML loading
### Synthesis Pipeline
- `synth/config.go``FreqConfig`, `ClassFreqConfigs`, `WaveformType`, `WaveformPresetHarmonics()`
- `synth/bank.go``NewBank(tau, cfgs map[TrafficClass]FreqConfig)` — injection point for merged config
- `encode/mp3.go``RunSynthesis(snapshots, outputPath, freqCfgs)` — pipeline entry
### CLI
- `cmd/netsynth/main.go` — Cobra command, `--config` flag, `run()` dispatches to live/pcap modes
### Prior Context
- `.planning/phases/06-config-package-and-sound-overrides/06-CONTEXT.md` — Phase 6 decisions (TOML schema, merge semantics, validation)
No external specs — requirements fully captured in decisions above.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `classify.Rule` struct — Already has Protocol, DstPort, Class fields matching the TOML schema
- `classify.NewClassifier(rules []Rule)` — Accepts any rule slice, so prepending user rules is straightforward
- `config.Load()` — Existing TOML loading with BurntSushi/toml, validation, and merge pipeline
- `config.rawConfig` — Top-level decode struct, needs `Rules` field added
- `config.parseWaveform()` — Reusable for validating waveform strings in sound overrides
- `synth.NewBank(tau, cfgs)` — Already accepts arbitrary config maps (Phase 5 injection seam)
### Established Patterns
- First-match-wins rule ordering in `classify.DefaultRules`
- TOML strict decoding with `Undecoded()` for unknown key detection
- Pointer fields (`*float64`, `*string`) for partial override semantics
- Config loaded once at startup before capture (fail-fast)
### Integration Points
- `config.Load()` return value must expand to include user rules
- `cmd/netsynth/main.go:run()` — Prepend user rules before passing to `classify.NewClassifier()`
- `config.merge()` — Must handle new class names by creating `FreqConfig` entries with auto-assigned frequencies
- `--print-config` — New flag or subcommand in Cobra root command
</code_context>
<specifics>
## Specific Ideas
- Print-config should show commented TOML with `# default` / `# override` annotations and source path header
- Output to stdout so users can pipe to a file as a template
</specifics>
<deferred>
## Deferred Ideas
None — discussion stayed within phase scope.
</deferred>
---
*Phase: 07-custom-rules-and-print-config*
*Context gathered: 2026-03-26*
@@ -0,0 +1,73 @@
# Phase 7: Custom Rules and Print-Config - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-03-26
**Phase:** 07-custom-rules-and-print-config
**Areas discussed:** Custom rule TOML schema, Class name collision policy, Print-config output format, Sound assignment for custom classes
---
## Custom Rule TOML Schema
| Option | Description | Selected |
|--------|-------------|----------|
| Minimal: port + protocol + class | Matches existing Rule struct. Sound config in separate [sounds.X]. | ✓ |
| Inline sound: port + protocol + class + frequency/waveform | All-in-one rule block, mixes classification and sound concerns. | |
| Rich matching: port ranges, src/dst, regex | More expressive but significantly more complex. | |
**User's choice:** Minimal — port + protocol + class
**Notes:** Protocol is required. Port is optional (omit to match any port for the protocol).
---
## Class Name Collision Policy
| Option | Description | Selected |
|--------|-------------|----------|
| Treat as override | User's rule fires first (prepended), same class name. Simplest model. | ✓ |
| Reject with error | Startup error if user rule uses built-in class name. | |
| Namespace: prefix user classes | User classes get "user-" prefix. Adds naming complexity. | |
**User's choice:** Treat as override
**Notes:** Resolves the design question flagged in STATE.md since Phase 5 research.
---
## Print-Config Output Format
| Option | Description | Selected |
|--------|-------------|----------|
| Commented TOML | Valid TOML with comments showing default vs override. Pipeable to file. | ✓ |
| Plain TOML | Clean but doesn't show what's default vs overridden. | |
| Human-readable table | Formatted table, not valid TOML. | |
**User's choice:** Commented TOML to stdout
**Notes:** Output to stdout so `netsynth --print-config > template.toml` works. Shows source path in header comment.
---
## Sound Assignment for Custom Classes
| Option | Description | Selected |
|--------|-------------|----------|
| Auto-assign sensible defaults | Pick unused frequency + sine waveform. No silence. | ✓ |
| Require explicit [sounds.X] | Error if no matching sound config. More friction. | |
| Single fallback tone | All custom classes share one tone. Defeats distinct sounds purpose. | |
**User's choice:** Auto-assign sensible defaults
**Notes:** Satisfies RULE-03 (no silent gaps). Algorithm left to Claude's discretion.
---
## Claude's Discretion
- Auto-frequency assignment algorithm
- --print-config as flag vs subcommand
- Commented TOML formatting approach
- config.Load() return type extension
## Deferred Ideas
None — discussion stayed within phase scope.
@@ -0,0 +1,515 @@
# Phase 7: Custom Rules and Print-Config - Research
**Researched:** 2026-03-26
**Domain:** Go config parsing (BurntSushi/toml), rule system extension, TOML serialization
**Confidence:** HIGH
## Summary
Phase 7 adds two related features: user-defined TOML classification rules that prepend before built-in `DefaultRules`, and a `--print-config` flag that serializes the full effective config to stdout as commented TOML without starting a capture. Both features are pure Go additions with no new dependencies — the entire implementation works within the existing stack.
The rule parsing extension is straightforward: add a `Rules []RawRule` field to `rawConfig`, validate required fields (`protocol`, `class`), and prepend the parsed `[]classify.Rule` slice before `classify.DefaultRules` in `main.go`. BurntSushi/toml's `Undecoded()` mechanism already catches typos in `[[rules]]` blocks (verified experimentally — unknown fields in array-of-table entries appear in `Undecoded()` as `rules.field_name`). Auto-frequency assignment for new class names uses FNV-32a hash of the class name mapped to a 12002400 Hz range (above all 14 built-in frequencies which top out at 1047 Hz), producing deterministic and collision-resistant results.
The `--print-config` implementation has two design paths: Cobra flag on the root command (simpler, consistent with the existing flag-on-root pattern) or a Cobra subcommand. The flag path is recommended as it mirrors how `--list-interfaces` works. The output format uses manual string building (not the TOML encoder) to support `# default` / `# override` annotations that the encoder cannot produce.
**Primary recommendation:** Extend `config.Load()` to return a `LoadResult` struct (freqCfgs + user rules), prepend user rules in `main.go`, use FNV-32a for auto-frequency assignment, and implement `--print-config` as a flag that triggers early-exit in the `run()` function.
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Custom rules use TOML array-of-tables `[[rules]]` with three fields: `port` (uint16, optional — omit to match any port), `protocol` (string, required — "tcp", "udp", or "icmp"), and `class` (string, required — the TrafficClass name). Sound configuration for the class goes in a separate `[sounds.<class>]` block.
- **D-02:** Port is optional. When omitted (or 0), the rule matches all traffic for the given protocol, mirroring the existing `Rule.DstPort = 0` semantics in `classify.DefaultRules`.
- **D-03:** Protocol is required. No implicit "match both TCP and UDP" behavior. User must write separate rules for each protocol.
- **D-04:** User-defined rules are prepended before built-in `DefaultRules` (RULE-02). First-match-wins semantics are preserved. A user rule for port 443/tcp fires before the built-in HTTPS rule.
- **D-05:** Rules within the TOML `[[rules]]` array maintain their file order. First rule in the file is first to match.
- **D-06:** User-defined class names that match built-in names (e.g., `class = "HTTPS"`) are treated as overrides, not errors. The user's rule fires first (prepended), so traffic matching it gets classified under the same built-in class name via the user rule. Sound config in `[sounds.HTTPS]` still applies.
- **D-07:** New class names that have no `[sounds.<class>]` entry automatically get sensible defaults: a frequency from an unused range and sine waveform. This satisfies RULE-03 (no silent gaps for user-defined classes).
- **D-08:** (Claude's Discretion) The auto-assignment algorithm — how to pick frequencies for new classes that don't collide with built-in frequencies. Could use a hash of the class name, a sequential pool, or a deterministic spread across an unused frequency band.
- **D-09:** `--print-config` outputs the full effective config (defaults merged with user overrides and custom rules) as commented TOML. Comments indicate which values are defaults vs overrides. This satisfies CFG-06.
- **D-10:** Output goes to stdout (pipeable). User can do `netsynth --print-config > template.toml` to create a config template. The command exits without starting a capture.
- **D-11:** If a config file is loaded (via auto-discovery or `--config`), show its source path in a header comment.
- **D-12:** The existing `config.Load()` function must be extended to parse `[[rules]]` blocks in addition to `[sounds.*]`. The `rawConfig` struct gains a `Rules []RawRule` field.
- **D-13:** `config.Load()` returns both the merged `FreqConfig` map and the user rules (as `[]classify.Rule`). The caller prepends user rules before `classify.DefaultRules`.
### Claude's Discretion
- How to extend `rawConfig` struct and `Load()` return type (tuple, struct, or new function)
- Auto-frequency assignment algorithm for custom classes without explicit sound config
- Whether `--print-config` is a Cobra subcommand or a flag on the root command
- How to format the commented TOML output (manual string building vs TOML encoder + post-processing)
### Deferred Ideas (OUT OF SCOPE)
None — discussion stayed within phase scope.
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| RULE-01 | User can define custom classification rules in TOML (match by port and/or protocol, assign class name and sound) | D-01 through D-05; `[[rules]]` TOML parsing with BurntSushi/toml confirmed working |
| RULE-02 | User-defined rules take priority over built-in rules (prepend before defaults) | D-04; `classify.NewClassifier(rules []Rule)` already accepts any rule slice; prepend in `main.go` |
| RULE-03 | User-defined class names automatically get a synthesis layer (no silent gaps) | D-07/D-08; FNV-32a auto-frequency in 12002400 Hz range; `synth.NewBank` already accepts arbitrary class maps |
| CFG-06 | User can run `netsynth --print-config` to see the effective config as commented TOML | D-09 through D-11; flag on root command triggering early-exit; manual string building for comment annotations |
</phase_requirements>
## Standard Stack
No new dependencies required. All features use existing libraries.
### Core (existing, no changes needed)
| Library | Version | Purpose | Notes |
|---------|---------|---------|-------|
| `github.com/BurntSushi/toml` | v1.6.0 | TOML decode + `Undecoded()` typo detection | Handles `[[rules]]` array-of-tables natively; `Undecoded()` catches typos in rule blocks |
| `github.com/spf13/cobra` | v1.10.2 | `--print-config` flag addition | PersistentPreRunE / early-exit pattern already used; add flag to root command |
| `hash/fnv` | stdlib | FNV-32a hash for auto-frequency assignment | No import needed — already in Go stdlib |
| `fmt` | stdlib | Manual TOML comment string building for print-config | Simplest approach for annotated output |
**Installation:** No new packages. `go build` with existing `go.mod` is sufficient.
## Architecture Patterns
### Recommended Project Structure (additions only)
```
config/
├── config.go — extend rawConfig + Load() return type
└── config_test.go — add tests for [[rules]] parsing, validate, auto-freq
cmd/netsynth/
└── main.go — --print-config flag, prepend user rules, printConfig()
```
### Pattern 1: rawConfig + Load() Return Type Extension
**What:** Add `Rules []RawRule` to `rawConfig`. Change `Load()` to return a `LoadResult` struct instead of a bare map.
**When to use:** Prefer a struct return over a tuple `(map, []Rule, error)` — Go tuples with 3+ values become unwieldy at the call site.
**Recommended struct:**
```go
// Source: internal design — no external library required
type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule
ConfigPath string // "" if no config loaded (for --print-config header comment)
}
type RawRule struct {
Port *uint16 `toml:"port"`
Protocol string `toml:"protocol"`
Class string `toml:"class"`
}
type rawConfig struct {
Sounds map[string]SoundOverride `toml:"sounds"`
Rules []RawRule `toml:"rules"`
}
```
**Call site in main.go:**
```go
result, err := config.Load(configPath)
if err != nil {
return err
}
allRules := append(result.UserRules, classify.DefaultRules...)
classifier := classify.NewClassifier(allRules)
```
### Pattern 2: RawRule Validation
**What:** Validate each `RawRule` before converting to `classify.Rule`. Required fields: `protocol` (non-empty, must be "tcp"/"udp"/"icmp"). `class` must be non-empty. `port` is optional.
**Port omission semantics:** In TOML, a missing `port` key means the struct field stays at its zero value. Using `*uint16` (pointer) lets us distinguish "omitted" from "port = 0". In practice, both map to `DstPort: 0` (match-any-port), so a `uint16` field (non-pointer) also works here — the distinction is only meaningful for validation messages. Use `*uint16` to match D-02 intent and for consistency with `SoundOverride` pointer fields.
**Validation function:**
```go
func validateRules(rules []RawRule) error {
validProtocols := map[string]bool{"tcp": true, "udp": true, "icmp": true}
for i, r := range rules {
if r.Protocol == "" {
return fmt.Errorf("config: rules[%d]: protocol is required", i)
}
if !validProtocols[r.Protocol] {
return fmt.Errorf("config: rules[%d]: invalid protocol %q — valid: tcp, udp, icmp", i, r.Protocol)
}
if r.Class == "" {
return fmt.Errorf("config: rules[%d]: class is required", i)
}
}
return nil
}
```
**Conversion to classify.Rule:**
```go
func convertRules(raw []RawRule) []classify.Rule {
result := make([]classify.Rule, len(raw))
for i, r := range raw {
var port uint16
if r.Port != nil {
port = *r.Port
}
result[i] = classify.Rule{
Protocol: r.Protocol,
DstPort: port,
Class: classify.TrafficClass(r.Class),
}
}
return result
}
```
### Pattern 3: Auto-Frequency Assignment via FNV-32a
**What:** For custom class names with no `[sounds.<class>]` block, assign a frequency deterministically from the class name using FNV-32a hash. Map to 12002400 Hz in 50 Hz steps.
**Why FNV-32a:** Fast, deterministic, already in stdlib, zero collisions observed across realistic class names. The 12002400 Hz range is entirely above the highest built-in frequency (1047 Hz for ClassUnknown4), so no overlap is possible.
**Verified with test:** The 24-step spread (1200, 1250, ..., 2350 Hz) gives clean frequency assignments: "MyApp"→1500 Hz, "GameServer"→1800 Hz, "MediaStream"→2150 Hz, "VoIP"→1750 Hz, "Database"→2000 Hz.
```go
// Source: stdlib hash/fnv — no import required beyond "hash/fnv"
import "hash/fnv"
func autoAssignFreq(className string) float64 {
h := fnv.New32a()
h.Write([]byte(className))
const (
baseHz = 1200.0
stepHz = 50.0
numSteps = 24 // range: 12002350 Hz
)
step := h.Sum32() % numSteps
return baseHz + float64(step)*stepHz
}
```
**Integration point in `merge()`:** After processing all `Sounds` overrides, iterate user rules. For each rule whose `Class` is not in the defaults map and has no `[sounds.<class>]` entry, call `autoAssignFreq` and create a `FreqConfig` with `WaveformSine`.
```go
// In config.merge() or a new mergeCustomClasses() helper:
func addAutoFreqEntries(
cfgs map[classify.TrafficClass]synth.FreqConfig,
userRules []classify.Rule,
) {
for _, rule := range userRules {
class := rule.Class
if _, exists := cfgs[class]; !exists {
baseHz := autoAssignFreq(string(class))
cfgs[class] = synth.FreqConfig{
BaseHz: baseHz,
WaveformType: synth.WaveformSine,
Harmonics: synth.WaveformPresetHarmonics(synth.WaveformSine, baseHz, synth.SampleRate),
Pan: 0.0,
}
}
}
}
```
### Pattern 4: --print-config as Flag on Root Command
**What:** Add `--print-config` bool flag. In `run()`, check the flag early and call a `printConfig()` function that writes to stdout, then return nil without starting a capture.
**Why flag over subcommand:** Consistent with `--list-interfaces` (existing pattern). Both are "inspect mode" flags that short-circuit the main capture path. Subcommand would require the user to write `netsynth print-config` rather than `netsynth --print-config`, deviating from the established CLI style.
```go
// In main():
var printConfigFlag bool
rootCmd.Flags().BoolVar(&printConfigFlag, "print-config", false, "Print effective config as commented TOML and exit")
// In run():
if printConfigFlag {
return runPrintConfig(configPath)
}
```
**runPrintConfig() structure:**
```go
func runPrintConfig(configPath string) error {
result, err := config.Load(configPath)
if err != nil {
return err
}
output, err := config.PrintConfig(result)
if err != nil {
return err
}
fmt.Print(output)
return nil
}
```
### Pattern 5: PrintConfig Output Format
**What:** Manual string building using `fmt.Fprintf` to a `strings.Builder`. The BurntSushi/toml encoder cannot add comments, so manual building is the correct approach.
**Output structure:**
```toml
# NetSynth effective configuration
# Config source: /home/user/.config/netsynth/config.toml
# Generated: 2026-03-26
# Custom classification rules (prepended before built-in rules)
# [[rules]]
# port = 8080
# protocol = "tcp"
# class = "MyApp"
[sounds]
# ICMP — 65.0 Hz (default)
[sounds.ICMP]
frequency = 65.0
waveform = "custom"
# HTTPS — 300.0 Hz (override)
[sounds.HTTPS]
frequency = 300.0
waveform = "sine"
```
**Comment semantics:**
- `# (default)` — value came from `synth.ClassFreqConfigs`
- `# (override)` — value was set in the user's config file
- `# (auto-assigned)` — frequency was auto-generated for a user-defined class
**Key implementation note:** Custom class entries added by `addAutoFreqEntries()` need their origin tracked. The `LoadResult` or a separate annotation map needs to carry which classes are auto-assigned vs user-overridden vs defaults. Simplest approach: `PrintConfig()` receives the `LoadResult` and compares against `synth.ClassFreqConfigs` to determine annotation.
### Pattern 6: Undecoded() and [[rules]] Interaction
**Verified behavior (experimental):** BurntSushi/toml's `Undecoded()` correctly catches unknown fields in `[[rules]]` blocks. A typo like `typo_field = "bad"` in a `[[rules]]` entry appears in `Undecoded()` as `rules.typo_field`. The existing `parseFile()` logic handles this automatically — no changes to the undecoded key check are needed.
**Important:** Class-name typos in `[sounds.<class>]` are STILL not caught (pre-existing limitation documented in the existing code comment). This is unchanged behavior for Phase 7.
### Anti-Patterns to Avoid
- **Returning a tuple `(map, []Rule, error)` from Load():** Three-value tuples at call sites are verbose and error-prone. Use a `LoadResult` struct.
- **Using the TOML encoder for print-config output:** The encoder cannot add `# (default)` comments. Manual `fmt.Fprintf` to `strings.Builder` is the correct approach.
- **Modifying `classify.DefaultRules` in place:** Always prepend user rules as a new slice. `DefaultRules` is a package-level var that must not be mutated. Use `append(userRules, classify.DefaultRules...)` to create a fresh slice.
- **Silent auto-frequency collision:** If two user-defined classes hash to the same frequency step, they will produce the same tone. This is acceptable for v1.1 (the probability is low with 24 steps) but document it in comments.
- **Placing `--print-config` check after config validation:** The check should occur early in `run()` — right after config load, before any interface or output validation. The user should be able to run `netsynth --print-config` without specifying `-i`.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| TOML parsing with typo detection | Custom parser | BurntSushi/toml `Undecoded()` | Already used; handles `[[rules]]` natively |
| Frequency hash | Custom hash function | stdlib `hash/fnv` FNV-32a | Zero-dependency, deterministic, already stdlib |
| CLI flag parsing | Manual arg parsing | Cobra flag registration | Consistent with all existing flags |
**Key insight:** Every mechanism needed for Phase 7 already exists in the codebase. The risk is over-engineering: the entire implementation is struct extension + prepend + string building.
## Common Pitfalls
### Pitfall 1: Mutating classify.DefaultRules
**What goes wrong:** `append(classify.DefaultRules, userRules...)` prepends to the wrong end and may mutate the backing array of `DefaultRules` if the slice has capacity.
**Why it happens:** Go slice append behavior with shared backing arrays.
**How to avoid:** Always build the combined slice as `append(userRules, classify.DefaultRules...)` — user rules first, then defaults. This also gives the correct prepend order (D-04).
**Warning signs:** Built-in rules fire before user rules for the same port/protocol.
### Pitfall 2: --print-config Requires -i (incorrect)
**What goes wrong:** The `run()` function checks for `-i` / `--read` before checking `--print-config`, causing `netsynth --print-config` to fail with "interface required".
**Why it happens:** The interface-required validation runs before the print-config check.
**How to avoid:** Check `printConfigFlag` at the very top of `run()`, before the interface validation block. This matches how `listIfaces` is handled.
**Warning signs:** `netsynth --print-config` returns "interface required" error instead of config output.
### Pitfall 3: Undecoded() Reports sounds.<class>.frequency as Unknown
**What goes wrong:** After adding `Rules []RawRule` to `rawConfig`, the `Undecoded()` check may report `sounds.MyApp.frequency` as unknown if the `SoundOverride` struct is not correctly decoded.
**Why it happens:** This was a concern during research but was verified NOT to occur — `[sounds.MyApp]` with a `SoundOverride` struct value decodes correctly alongside `[[rules]]`. No issue exists.
**How to avoid:** N/A — verified working. Document in code that both sections coexist correctly.
### Pitfall 4: Auto-Frequency Called for Built-in Class Names
**What goes wrong:** If a user writes `class = "HTTPS"` in `[[rules]]`, `addAutoFreqEntries()` must not overwrite the existing HTTPS entry with an auto-generated frequency.
**Why it happens:** The auto-assign loop checks `if _, exists := cfgs[class]; !exists` — built-in classes ARE in the defaults map, so this guard works correctly. But only if `addAutoFreqEntries()` runs AFTER `merge()` has already applied `[sounds.*]` overrides.
**How to avoid:** Call `addAutoFreqEntries()` as the last step in the merge pipeline, after `merge(defaults, raw.Sounds)`. The class-exists check then correctly skips both built-in and user-overridden classes.
### Pitfall 5: print-config Missing User Rules Section
**What goes wrong:** `printConfig()` shows `[sounds.*]` entries but omits `[[rules]]` entries, making the output not round-trippable.
**Why it happens:** Developer focuses on the sounds section (the existing config domain) and forgets rules.
**How to avoid:** The `LoadResult` must include both `UserRules []classify.Rule` and `ConfigPath string`. The `PrintConfig()` function must emit the `[[rules]]` section before the `[sounds.*]` section, using the user rule slice.
### Pitfall 6: validate() Must Run Before convertRules()
**What goes wrong:** An empty `protocol` or `class` field in `[[rules]]` gets silently converted to a `classify.Rule` with empty strings, producing confusing runtime behavior.
**Why it happens:** `convertRules()` has no validation; it just copies fields.
**How to avoid:** Call `validateRules(raw.Rules)` inside `validate()` (the existing validation entry point), before conversion. Fail fast at startup.
## Code Examples
### TOML File with Custom Rules (user-facing format)
```toml
# Classify custom app traffic on port 8080
[[rules]]
port = 8080
protocol = "tcp"
class = "MyApp"
# Match all UDP traffic to a custom class (port omitted = match any)
[[rules]]
protocol = "udp"
class = "AllUDP"
# Give MyApp a custom sound
[sounds.MyApp]
frequency = 300.0
waveform = "sine"
# Override built-in HTTPS sound
[sounds.HTTPS]
frequency = 400.0
```
### rawConfig Extension
```go
// config/config.go
type RawRule struct {
Port *uint16 `toml:"port"`
Protocol string `toml:"protocol"`
Class string `toml:"class"`
}
type rawConfig struct {
Sounds map[string]SoundOverride `toml:"sounds"`
Rules []RawRule `toml:"rules"`
}
```
### LoadResult Struct (replaces bare map return)
```go
type LoadResult struct {
FreqCfgs map[classify.TrafficClass]synth.FreqConfig
UserRules []classify.Rule
ConfigPath string // populated path or "" if auto-discovery found nothing
}
func Load(configPath string) (LoadResult, error) { ... }
```
### Prepend User Rules in main.go
```go
result, err := config.Load(configPath)
if err != nil {
return err
}
// D-04: user rules prepend before built-ins; first-match-wins
allRules := append(result.UserRules, classify.DefaultRules...)
classifier := classify.NewClassifier(allRules)
```
### FNV-32a Auto-Frequency
```go
import "hash/fnv"
func autoAssignFreq(className string) float64 {
h := fnv.New32a()
h.Write([]byte(className))
const (
baseHz = 1200.0
stepHz = 50.0
numSteps = uint32(24)
)
return baseHz + float64(h.Sum32()%numSteps)*stepHz
}
```
## State of the Art
No changes to the underlying technology stack. All patterns are internal to the codebase.
| Old Behavior | New Behavior | When Changed | Impact |
|---|---|---|---|
| `config.Load()` returns `map[classify.TrafficClass]synth.FreqConfig` | Returns `LoadResult` struct with FreqCfgs + UserRules + ConfigPath | Phase 7 | All callers of `Load()` need update (currently 1 call site: `main.go:run()`) |
| Unknown class names in `[sounds.*]` silently ignored (warning) | Still ignored with warning, but user-defined class names from `[[rules]]` get auto-freq entries instead | Phase 7 | New behavior for Phase 7 class names; old warning behavior preserved for truly unknown names |
| `merge()` only processes sound overrides | `merge()` + `addAutoFreqEntries()` also handles new class synthesis entries | Phase 7 | Synthesis bank grows dynamically with user-defined classes |
## Environment Availability
Step 2.6: SKIPPED (no external dependencies identified — Phase 7 is a pure Go code extension using existing stack).
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Go standard `testing` package |
| Config file | None (no pytest.ini / jest.config equivalent) |
| Quick run command | `go test ./config/... ./cmd/netsynth/...` |
| Full suite command | `go test ./...` |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| RULE-01 | `[[rules]]` block in TOML parses correctly into `RawRule` slice | unit | `go test ./config/... -run TestLoadCustomRules` | ❌ Wave 0 |
| RULE-01 | Port field omitted → DstPort 0 (match-any) | unit | `go test ./config/... -run TestLoadCustomRuleNoPort` | ❌ Wave 0 |
| RULE-01 | Missing `protocol` field → error | unit | `go test ./config/... -run TestLoadCustomRuleMissingProtocol` | ❌ Wave 0 |
| RULE-01 | Missing `class` field → error | unit | `go test ./config/... -run TestLoadCustomRuleMissingClass` | ❌ Wave 0 |
| RULE-01 | Typo in `[[rules]]` field → error naming bad key | unit | `go test ./config/... -run TestLoadCustomRuleUnknownField` | ❌ Wave 0 |
| RULE-02 | User rule for port 443/tcp fires before built-in HTTPS rule | unit | `go test ./config/... -run TestUserRulesPrepend` | ❌ Wave 0 |
| RULE-03 | Class name with no `[sounds.*]` entry gets auto-freq entry in FreqCfgs | unit | `go test ./config/... -run TestAutoFreqAssignment` | ❌ Wave 0 |
| RULE-03 | Auto-freq is deterministic (same class name → same frequency) | unit | `go test ./config/... -run TestAutoFreqDeterministic` | ❌ Wave 0 |
| RULE-03 | Built-in class names NOT overwritten by auto-freq | unit | `go test ./config/... -run TestAutoFreqSkipsBuiltins` | ❌ Wave 0 |
| CFG-06 | `--print-config` flag registered on root command | unit | `go test ./cmd/netsynth/... -run TestPrintConfigFlagRegistered` | ❌ Wave 0 |
| CFG-06 | `--print-config` exits without requiring `-i` flag | unit | `go test ./cmd/netsynth/... -run TestPrintConfigNoInterface` | ❌ Wave 0 |
| CFG-06 | Print-config output contains all 14 default class names | unit | `go test ./config/... -run TestPrintConfigContainsAllClasses` | ❌ Wave 0 |
| CFG-06 | Print-config output contains `[[rules]]` section when user rules are present | unit | `go test ./config/... -run TestPrintConfigContainsRules` | ❌ Wave 0 |
| CFG-06 | Print-config includes source path in header comment when config loaded | unit | `go test ./config/... -run TestPrintConfigSourcePath` | ❌ Wave 0 |
### Sampling Rate
- **Per task commit:** `go test ./config/... ./cmd/netsynth/...`
- **Per wave merge:** `go test ./...`
- **Phase gate:** `go test ./...` green before `/gsd:verify-work`
### Wave 0 Gaps
All test functions listed above are new — no existing test file covers Phase 7 behavior. Tests should be added to:
- `config/config_test.go` — all `config` package tests (follow existing `writeTOML` helper pattern)
- `cmd/netsynth/main_test.go` — all `cmd/netsynth` flag tests (follow existing `newTestCmd()` pattern)
No new test files needed — extend the existing test files.
## Open Questions
1. **PrintConfig annotation tracking for auto-assigned classes**
- What we know: `addAutoFreqEntries()` adds entries to `FreqCfgs` for new class names
- What's unclear: `PrintConfig()` needs to know which entries are auto-assigned (vs default vs user-override) to annotate them correctly
- Recommendation: Add an `AutoClasses map[classify.TrafficClass]bool` field to `LoadResult`, populated by `addAutoFreqEntries()`. `PrintConfig()` consults this map.
2. **Cobra --print-config placement: before or after config load**
- What we know: `--print-config` needs config loaded to show effective values
- What's unclear: What if the user runs `netsynth --print-config` with no config file?
- Recommendation: Always call `config.Load(configPath)` before printing. If no config file is found (auto-discovery returns nothing), the output shows all defaults — which is the most useful behavior.
## Sources
### Primary (HIGH confidence)
- Source code: `config/config.go` — read directly; rawConfig struct, Load(), merge(), validate(), parseFile() all verified
- Source code: `classify/rules.go` — DefaultRules slice, Rule struct verified
- Source code: `classify/classifier.go` — NewClassifier(rules []Rule) injection point verified
- Source code: `synth/bank.go` — NewBank(tau, cfgs) accepts arbitrary class maps confirmed
- Source code: `synth/config.go` — ClassFreqConfigs frequency range 651047 Hz confirmed; WaveformPresetHarmonics verified
- Source code: `cmd/netsynth/main.go` — current Load() call site; listIfaces early-exit pattern verified
- Experimental: BurntSushi/toml `Undecoded()` behavior with `[[rules]]` — verified by running test code against go.mod-pinned v1.6.0
- Experimental: FNV-32a frequency distribution — verified by running Go code; 24 distinct frequencies in 12002400 Hz range
### Secondary (MEDIUM confidence)
- Source code: `config/config_test.go` — test patterns for writeTOML, Load() behavior; test style confirmed
- Source code: `cmd/netsynth/main_test.go` — newTestCmd() pattern, flag registration test style confirmed
### Tertiary (LOW confidence)
- None.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — no new dependencies; all libraries verified against go.mod
- Architecture: HIGH — all integration points verified by reading actual source code
- Pitfalls: HIGH — Pitfalls 1, 2, 4, 5 verified by code inspection; Pitfall 3 experimentally verified as non-issue
- Test patterns: HIGH — existing test file structure read directly
**Research date:** 2026-03-26
**Valid until:** Stable — 90 days (pure Go, no external dependencies, stable TOML library)
@@ -0,0 +1,86 @@
---
phase: 7
slug: custom-rules-and-print-config
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-03-26
---
# Phase 7 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Go standard `testing` package |
| **Config file** | None |
| **Quick run command** | `go test ./config/... ./cmd/netsynth/...` |
| **Full suite command** | `go test ./...` |
| **Estimated runtime** | ~5 seconds |
---
## Sampling Rate
- **After every task commit:** Run `go test ./config/... ./cmd/netsynth/...`
- **After every plan wave:** Run `go test ./...`
- **Before `/gsd:verify-work`:** Full suite must be green
- **Max feedback latency:** 5 seconds
---
## Per-Task Verification Map
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
| 07-01-01 | 01 | 1 | RULE-01 | unit | `go test ./config/... -run TestLoadCustomRules` | ❌ W0 | ⬜ pending |
| 07-01-02 | 01 | 1 | RULE-01 | unit | `go test ./config/... -run TestLoadCustomRuleNoPort` | ❌ W0 | ⬜ pending |
| 07-01-03 | 01 | 1 | RULE-01 | unit | `go test ./config/... -run TestLoadCustomRuleMissingProtocol` | ❌ W0 | ⬜ pending |
| 07-01-04 | 01 | 1 | RULE-01 | unit | `go test ./config/... -run TestLoadCustomRuleMissingClass` | ❌ W0 | ⬜ pending |
| 07-01-05 | 01 | 1 | RULE-01 | unit | `go test ./config/... -run TestLoadCustomRuleUnknownField` | ❌ W0 | ⬜ pending |
| 07-01-06 | 01 | 1 | RULE-02 | unit | `go test ./config/... -run TestUserRulesPrepend` | ❌ W0 | ⬜ pending |
| 07-01-07 | 01 | 1 | RULE-03 | unit | `go test ./config/... -run TestAutoFreqAssignment` | ❌ W0 | ⬜ pending |
| 07-01-08 | 01 | 1 | RULE-03 | unit | `go test ./config/... -run TestAutoFreqDeterministic` | ❌ W0 | ⬜ pending |
| 07-01-09 | 01 | 1 | RULE-03 | unit | `go test ./config/... -run TestAutoFreqSkipsBuiltins` | ❌ W0 | ⬜ pending |
| 07-02-01 | 02 | 2 | CFG-06 | unit | `go test ./cmd/netsynth/... -run TestPrintConfigFlagRegistered` | ❌ W0 | ⬜ pending |
| 07-02-02 | 02 | 2 | CFG-06 | unit | `go test ./cmd/netsynth/... -run TestPrintConfigNoInterface` | ❌ W0 | ⬜ pending |
| 07-02-03 | 02 | 2 | CFG-06 | unit | `go test ./config/... -run TestPrintConfigContainsAllClasses` | ❌ W0 | ⬜ pending |
| 07-02-04 | 02 | 2 | CFG-06 | unit | `go test ./config/... -run TestPrintConfigContainsRules` | ❌ W0 | ⬜ pending |
| 07-02-05 | 02 | 2 | CFG-06 | unit | `go test ./config/... -run TestPrintConfigSourcePath` | ❌ W0 | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `config/config_test.go` — add test stubs for RULE-01, RULE-02, RULE-03 (extend existing file using `writeTOML` helper pattern)
- [ ] `cmd/netsynth/main_test.go` — add test stubs for CFG-06 (extend existing file using `newTestCmd()` pattern)
*Existing infrastructure covers framework install — `go test` already works.*
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Custom rule produces distinct tone in MP3 output | RULE-01 | Audio output requires human ear verification | Run `netsynth -i lo --config test.toml -o out.mp3`, listen for distinct tone on custom rule port |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
- [ ] Wave 0 covers all MISSING references
- [ ] No watch-mode flags
- [ ] Feedback latency < 5s
- [ ] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
@@ -0,0 +1,115 @@
---
phase: 07-custom-rules-and-print-config
verified: 2026-03-26T21:10:00Z
status: passed
score: 10/10 must-haves verified
---
# Phase 7: Custom Rules and Print-Config Verification Report
**Phase Goal:** Users can define their own traffic classification rules in TOML, assign custom sounds to them, and inspect the full effective config before capture begins
**Verified:** 2026-03-26T21:10:00Z
**Status:** passed
**Re-verification:** No — initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|---------|
| 1 | TOML `[[rules]]` blocks parse into `classify.Rule` slices | VERIFIED | `convertRules()` in config.go:192-206; `TestLoadCustomRules` passes; `TestLoadCustomRuleNoPort` passes |
| 2 | Missing protocol or class in a rule produces a clear error at startup | VERIFIED | `validateRules()` in config.go:175-189; `TestLoadCustomRuleMissingProtocol`, `TestLoadCustomRuleMissingClass`, `TestLoadCustomRuleInvalidProtocol` all pass |
| 3 | User rules are returned separately from FreqCfgs for caller to prepend | VERIFIED | `LoadResult.UserRules []classify.Rule` field in config.go:48-52; `TestUserRulesPrepend` passes |
| 4 | New class names without explicit sound config get auto-assigned frequencies in 1200-2400 Hz range | VERIFIED | `autoAssignFreq()` in config.go:210-219 uses FNV-32a; range [1200, 2350]; `TestAutoFreqAssignment` and `TestAutoFreqDeterministic` pass |
| 5 | Built-in class names in user rules do not get overwritten by auto-freq | VERIFIED | `addAutoFreqEntries()` checks `if _, exists := cfgs[rule.Class]; !exists` before assigning; `TestAutoFreqSkipsBuiltins` verifies HTTPS stays at 175.0 Hz |
| 6 | User runs `netsynth --print-config` and sees full effective config as commented TOML on stdout without capture starting | VERIFIED | `runPrintConfig()` in main.go:114-122; `if printConfig` check at main.go:73 fires before interface-required validation; `TestPrintConfigNoInterface` passes |
| 7 | User rules prepend before built-in rules so first-match-wins gives user priority | VERIFIED | `append(result.UserRules, classify.DefaultRules...)` in both `runLiveMode` (main.go:139) and `runPcapMode` (main.go:205); `TestUserRulesPrepend` confirms prepend order |
| 8 | Print-config output shows source path when config file loaded | VERIFIED | `PrintConfig()` emits `# Config source: <path>` when `result.ConfigPath != ""`; `TestPrintConfigSourcePath` passes |
| 9 | Print-config output annotates defaults vs overrides vs auto-assigned | VERIFIED | `classAnnotation()` in config.go:340-353 returns "default", "override", or "auto-assigned"; `TestPrintConfigDefaultAnnotation`, `TestPrintConfigOverrideAnnotation`, `TestPrintConfigAutoAssignedAnnotation` all pass |
| 10 | Print-config output includes `[[rules]]` section when user rules are present | VERIFIED | `PrintConfig()` emits `[[rules]]` section when `len(result.UserRules) > 0` (config.go:284-295); `TestPrintConfigContainsRules` passes |
**Score:** 10/10 truths verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `config/config.go` | RawRule, LoadResult, validateRules, convertRules, autoAssignFreq, addAutoFreqEntries, PrintConfig | VERIFIED | All 7 constructs present; file is 396 lines, fully substantive |
| `config/config_test.go` | Tests for rule parsing, validation, auto-freq, LoadResult, PrintConfig | VERIFIED | 29 tests total (8 pre-existing + 13 Plan-01 + 8 Plan-02); all pass |
| `cmd/netsynth/main.go` | --print-config flag, runPrintConfig(), user rule prepend | VERIFIED | Flag registered at main.go:49; runPrintConfig at main.go:114; prepend in both runLiveMode and runPcapMode |
| `cmd/netsynth/main_test.go` | Tests for --print-config flag | VERIFIED | TestPrintConfigFlagRegistered, TestPrintConfigNoInterface, TestPrintConfigWithConfigFile all present and pass |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| config/config.go | classify/rules.go | convertRules produces []classify.Rule | WIRED | `classify.Rule` used at lines 49, 78, 192-206, 225 — manual grep confirmed |
| config/config.go | synth/config.go | autoAssignFreq creates FreqConfig entries with WaveformPresetHarmonics | WIRED | `synth.WaveformPresetHarmonics` called at lines 232, 384, 390 — manual grep confirmed |
| cmd/netsynth/main.go | config/config.go | runPrintConfig calls config.Load then config.PrintConfig | WIRED | `config.PrintConfig(result)` at main.go:119 — manual grep confirmed |
| cmd/netsynth/main.go | classify/rules.go | append(result.UserRules, classify.DefaultRules...) | WIRED | gsd-tools verified; pattern present at main.go:139 and main.go:205 |
Note: gsd-tools key-link checker reported false negatives for the three pattern matches involving escaped dots (`\.`). All four links are confirmed present via manual grep.
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|---------------|--------|--------------------|--------|
| config/config.go PrintConfig | result.UserRules, result.FreqCfgs, result.AutoClasses | config.Load() parsing TOML + FNV-32a hash | Yes — real TOML parsing, classify.Rule slices, synth.FreqConfig map | FLOWING |
| cmd/netsynth/main.go runLiveMode | allRules via result.UserRules | config.Load() -> convertRules() -> user TOML | Yes — user rules prepended to classify.DefaultRules | FLOWING |
| cmd/netsynth/main.go runPcapMode | allRules via result.UserRules | config.Load() -> convertRules() -> user TOML | Yes — same prepend pattern as runLiveMode | FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| config package: all 29 tests pass | `go test ./config/... -count=1` | ok (0.017s) | PASS |
| cmd/netsynth package: all 13 tests pass | `go test ./cmd/netsynth/... -count=1` | ok (0.013s) | PASS |
| Full test suite: all 7 packages | `go test ./... -count=1` | ok all 7 packages | PASS |
| Static analysis | `go vet ./...` | no issues | PASS |
| PrintConfig includes all 14 class names | TestPrintConfigContainsAllClasses | PASS | PASS |
| --print-config works without -i | TestPrintConfigNoInterface | PASS | PASS |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|---------|
| RULE-01 | 07-01 | User can define custom classification rules in TOML (match by port and/or protocol, assign class name) | SATISFIED | `RawRule` struct + `rawConfig.Rules []RawRule` + TOML `[[rules]]` parsing; TestLoadCustomRules and TestLoadCustomRuleNoPort verify parsing |
| RULE-02 | 07-01, 07-02 | User-defined rules take priority over built-in rules (prepend before defaults) | SATISFIED | `append(result.UserRules, classify.DefaultRules...)` in both runLiveMode and runPcapMode; TestUserRulesPrepend verifies order |
| RULE-03 | 07-01 | User-defined class names automatically get a synthesis layer (no silent gaps) | SATISFIED | `addAutoFreqEntries()` creates FreqConfig for unknown class names using FNV-32a in [1200, 2350] Hz; TestAutoFreqAssignment verifies entry exists with WaveformSine |
| CFG-06 | 07-02 | User can run `netsynth --print-config` to see the effective config as commented TOML | SATISFIED | `--print-config` flag registered; `runPrintConfig()` calls config.Load + config.PrintConfig + fmt.Print; fires before interface-required check; TestPrintConfigNoInterface confirms no -i needed |
**Orphaned requirements:** None. All four requirement IDs (RULE-01, RULE-02, RULE-03, CFG-06) are claimed by plan frontmatter and verified above. REQUIREMENTS.md traceability table confirms all four map to Phase 7.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| (none) | — | — | — | — |
No TODOs, FIXMEs, placeholder comments, empty return stubs, or hardcoded empty data found in modified files. The merge() function at config.go:368 does contain `return defaults` but returns the populated map after in-place mutation — this is correct behavior, not a stub.
### Human Verification Required
The following behaviors cannot be verified programmatically and require manual testing before production use:
#### 1. End-to-End TOML Round-Trip
**Test:** Create a `netsynth.toml` with multiple `[[rules]]` blocks (different ports, protocols, class names), run `netsynth --print-config`, copy the output to a new file, and load it again with `--print-config --config <copied-file>`.
**Expected:** Output from both invocations should show the same class frequencies and annotations.
**Why human:** Requires file creation, CLI invocation, and comparison of two output streams — not suitable for automated spot-check in a non-interactive environment.
#### 2. Custom Rule Sound Differentiation
**Test:** Create a TOML defining a custom rule for port 8080/tcp as "WebApp", run a capture or play a pcap with HTTP traffic on port 8080, and listen to the resulting MP3.
**Expected:** Port 8080 traffic should produce a distinct tone from port 80 (HTTP) traffic.
**Why human:** Requires audio playback and subjective listening — cannot be verified programmatically.
### Gaps Summary
No gaps. All 10 observable truths are verified, all 4 artifacts pass all three levels (exists, substantive, wired), all 4 key links are confirmed present in the code, all 4 requirements are satisfied, and the full test suite (7 packages, 29+ config tests, 13 main tests) passes cleanly.
---
_Verified: 2026-03-26T21:10:00Z_
_Verifier: Claude (gsd-verifier)_