ScuttleBot

feat(#35): topology policy engine — domain-agnostic abstraction layer between config and runtime

lmata 2026-04-02 05:11 trunk
Commit 29373fda3a73276662405f63675ab9b1f51eeeff1c31649f5dc4150ae120c7a4
--- a/internal/topology/policy.go
+++ b/internal/topology/policy.go
@@ -0,0 +1,141 @@
1
+package topology
2
+
3
+import (
4
+ "context"
5
+ "strings"
6
+ "time"
7
+
8
+ "github.com/conflicthq/scuttlebot/internal/config"
9
+)
10
+
11
+// ChannelType is the resolved policy for a class of channels.
12
+type ChannelType struct {
13
+ Name string
14
+ Prefix string
15
+ Autojoin []string
16
+ Supervision string
17
+ Ephemeral bool
18
+ TTL time.Duration
19
+}
20
+
21
+// EventType classifies a channel lifecycle event.
22
+type EventType string
23
+
24
+const (
25
+ EventCreated EventType = "created"
26
+ EventClosed EventType = "closed"
27
+ EventReopened EventType = "reopened"
28
+ EventTransferred EventType = "transferred"
29
+)
30
+
31
+// ChannelEvent is emitted on channel lifecycle transitions.
32
+type ChannelEvent struct {
33
+ Type EventType
34
+ Channel string
35
+ By string // nick that triggered the event
36
+ Meta map[string]string // optional domain-specific metadata
37
+}
38
+
39
+// EventHook is implemented by anything that reacts to channel lifecycle events.
40
+type EventHook interface {
41
+ OnChannelEvent(ctx context.Context, event ChannelEvent) error
42
+}
43
+
44
+// Policy is the domain-agnostic evaluation layer between topology config and
45
+// the runtime (IRC, bot invites, API). It answers questions like:
46
+//
47
+// - What type is #task.gh-42?
48
+// - Which bots should join #incident.p1?
49
+// - Where should summaries from #feature.auth surface?
50
+//
51
+// Rules come entirely from config — the Policy itself contains no hardcoded
52
+// domain knowledge.
53
+type Policy struct {
54
+ staticChannels []config.StaticChannelConfig
55
+ types []ChannelType
56
+}
57
+
58
+// NewPolicy constructs a Policy from the topology section of the config.
59
+func NewPolicy(cfg config.TopologyConfig) *Policy {
60
+ types := make([]ChannelType, 0, len(cfg.Types))
61
+ for _, t := range cfg.Types {
62
+ types = append(types, ChannelType{
63
+ Name: t.Name,
64
+ Prefix: t.Prefix,
65
+ Autojoin: append([]strinring(nil), t.Modes...),
66
+ Supervision: t.Supervision,
67
+ Ephemeral: t.Ephemeral,
68
+ TTL: t.TTL.Duration,
69
+ })
70
+ }
71
+ return &Policy{
72
+ staticChannels: append([]config.StaticChannelConfig(nil), cfg.Channels...),
73
+ types: types,
74
+ }
75
+}
76
+
77
+// Match returns the ChannelType for the given channel name by prefix, or nil
78
+// if no type matches. Channel names are matched after stripping the leading #.
79
+func (p *Policy) Match(channel string) *ChannelType {
80
+ slug := strings.TrimPrefix(channel, "#")
81
+ for i := range p.types {
82
+ if strings.HasPrefix(slug, p.types[i].Prefix) {
83
+ return &p.types[i]
84
+ }
85
+ }
86
+ return nil
87
+}
88
+
89
+// AutojoinFor returns the bot nicks that should join channel.
90
+// For dynamic channels this comes from the matching ChannelType.
91
+// For static channels it comes from the StaticChannelConfig.
92
+// Returns nil if no rule matches.
93
+func (p *Policy) AutojoinFor(channel string) []string {
94
+ // Check static channels first (exact match).
95
+ for _, sc := range p.staticChannels {
96
+ if strings.EqualFold(sc.Name, channel) {
97
+ return append([]string(nil), sc.Autojoin...)
98
+ }
99
+ }
100
+ if t := p.Match(channel); t != nil {
101
+ return append([]string(nil), t.Autojoin...)
102
+ }
103
+ return nil
104
+}
105
+
106
+// SupervisionFor returns the coordination/supervision channel for the given
107
+// channel, or an empty string if none is configured for its type.
108
+func (p *Policy) SupervisionFor(channel string) string {
109
+ if t := p.Match(channel); t != nil {
110
+ return t.Supervision
111
+ }
112
+ return ""
113
+}
114
+
115
+// TypeName returns the type name for the given channel, or "unknown".
116
+func (p *Policy) TypeName(channel string) string {
117
+ if t := p.Match(channel); t != nil {
118
+ return t.Name
119
+ }
120
+ return ""
121
+}
122
+
123
+// IsEphemeral reports whether channels of the matched type are ephemeral.
124
+func (p *Policy) IsEphemeral(channel string) bool {
125
+ if t := p.Match(channel); t != nil {
126
+ return t.Ephemeral
127
+ }
128
+ return false
129
+}
130
+
131
+// TTLFor returns the TTL for the matched channel type, or zero if none.
132
+func (p *Policy) TTLFor(channel string) time.Duration {
133
+ if t := p.Match(cStaticChannels returns the list of channels to provision at startup.
134
+func (p *Policy) StaticChannels() []config.StaticChannelConfig {
135
+ return append([]config.StaticChannelConfig(nil), p.staticChannels...)
136
+}
137
+
138
+// Types returns all registered channel types.
139
+func (p *Policy) Types() []ChannelType {
140
+ return append([]ChannelType(nil), p.types...)
141
+}
--- a/internal/topology/policy.go
+++ b/internal/topology/policy.go
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/topology/policy.go
+++ b/internal/topology/policy.go
@@ -0,0 +1,141 @@
1 package topology
2
3 import (
4 "context"
5 "strings"
6 "time"
7
8 "github.com/conflicthq/scuttlebot/internal/config"
9 )
10
11 // ChannelType is the resolved policy for a class of channels.
12 type ChannelType struct {
13 Name string
14 Prefix string
15 Autojoin []string
16 Supervision string
17 Ephemeral bool
18 TTL time.Duration
19 }
20
21 // EventType classifies a channel lifecycle event.
22 type EventType string
23
24 const (
25 EventCreated EventType = "created"
26 EventClosed EventType = "closed"
27 EventReopened EventType = "reopened"
28 EventTransferred EventType = "transferred"
29 )
30
31 // ChannelEvent is emitted on channel lifecycle transitions.
32 type ChannelEvent struct {
33 Type EventType
34 Channel string
35 By string // nick that triggered the event
36 Meta map[string]string // optional domain-specific metadata
37 }
38
39 // EventHook is implemented by anything that reacts to channel lifecycle events.
40 type EventHook interface {
41 OnChannelEvent(ctx context.Context, event ChannelEvent) error
42 }
43
44 // Policy is the domain-agnostic evaluation layer between topology config and
45 // the runtime (IRC, bot invites, API). It answers questions like:
46 //
47 // - What type is #task.gh-42?
48 // - Which bots should join #incident.p1?
49 // - Where should summaries from #feature.auth surface?
50 //
51 // Rules come entirely from config — the Policy itself contains no hardcoded
52 // domain knowledge.
53 type Policy struct {
54 staticChannels []config.StaticChannelConfig
55 types []ChannelType
56 }
57
58 // NewPolicy constructs a Policy from the topology section of the config.
59 func NewPolicy(cfg config.TopologyConfig) *Policy {
60 types := make([]ChannelType, 0, len(cfg.Types))
61 for _, t := range cfg.Types {
62 types = append(types, ChannelType{
63 Name: t.Name,
64 Prefix: t.Prefix,
65 Autojoin: append([]strinring(nil), t.Modes...),
66 Supervision: t.Supervision,
67 Ephemeral: t.Ephemeral,
68 TTL: t.TTL.Duration,
69 })
70 }
71 return &Policy{
72 staticChannels: append([]config.StaticChannelConfig(nil), cfg.Channels...),
73 types: types,
74 }
75 }
76
77 // Match returns the ChannelType for the given channel name by prefix, or nil
78 // if no type matches. Channel names are matched after stripping the leading #.
79 func (p *Policy) Match(channel string) *ChannelType {
80 slug := strings.TrimPrefix(channel, "#")
81 for i := range p.types {
82 if strings.HasPrefix(slug, p.types[i].Prefix) {
83 return &p.types[i]
84 }
85 }
86 return nil
87 }
88
89 // AutojoinFor returns the bot nicks that should join channel.
90 // For dynamic channels this comes from the matching ChannelType.
91 // For static channels it comes from the StaticChannelConfig.
92 // Returns nil if no rule matches.
93 func (p *Policy) AutojoinFor(channel string) []string {
94 // Check static channels first (exact match).
95 for _, sc := range p.staticChannels {
96 if strings.EqualFold(sc.Name, channel) {
97 return append([]string(nil), sc.Autojoin...)
98 }
99 }
100 if t := p.Match(channel); t != nil {
101 return append([]string(nil), t.Autojoin...)
102 }
103 return nil
104 }
105
106 // SupervisionFor returns the coordination/supervision channel for the given
107 // channel, or an empty string if none is configured for its type.
108 func (p *Policy) SupervisionFor(channel string) string {
109 if t := p.Match(channel); t != nil {
110 return t.Supervision
111 }
112 return ""
113 }
114
115 // TypeName returns the type name for the given channel, or "unknown".
116 func (p *Policy) TypeName(channel string) string {
117 if t := p.Match(channel); t != nil {
118 return t.Name
119 }
120 return ""
121 }
122
123 // IsEphemeral reports whether channels of the matched type are ephemeral.
124 func (p *Policy) IsEphemeral(channel string) bool {
125 if t := p.Match(channel); t != nil {
126 return t.Ephemeral
127 }
128 return false
129 }
130
131 // TTLFor returns the TTL for the matched channel type, or zero if none.
132 func (p *Policy) TTLFor(channel string) time.Duration {
133 if t := p.Match(cStaticChannels returns the list of channels to provision at startup.
134 func (p *Policy) StaticChannels() []config.StaticChannelConfig {
135 return append([]config.StaticChannelConfig(nil), p.staticChannels...)
136 }
137
138 // Types returns all registered channel types.
139 func (p *Policy) Types() []ChannelType {
140 return append([]ChannelType(nil), p.types...)
141 }
--- a/internal/topology/policy_test.go
+++ b/internal/topology/policy_test.go
@@ -0,0 +1,114 @@
1
+package topology
2
+
3
+import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/conflicthq/scuttlebot/internal/config"
8
+)
9
+
10
+func testPolicy() *Policy {
11
+ return NewPolicy(config.TopologyConfig{
12
+ Channels: []config.StaticChannelConfig{
13
+ {
14
+ Name: "#general",
15
+ Topic: "Fleet coordination",
16
+ Autojoin: []string{"bridge", "oracle", "scribe"},
17
+ },
18
+ {
19
+ Name: "#alerts",
20
+ Autojoin: []string{"bridge", "sentinel", "steward"},
21
+ },
22
+ },
23
+ Types: []config.ChannelTypeConfig{
24
+ {
25
+ Name: "task",
26
+ Prefix: "task.",
27
+ Autojoin: []string{"bridge", "scribe"},
28
+ Supervision: "#general",
29
+ Ephemeral: true,
30
+ TTL: config.Duration{Duration: 72 * time.Hour},
31
+ },
32
+ {
33
+ Name: "sprint",
34
+ Prefix: "sprint.",
35
+ Autojoin: []string{"bridge", "oracle", "herald"},
36
+ Supervision: "",
37
+ },
38
+ {
39
+ Name: "incident",
40
+ Prefix: "incident.",
41
+ Autojoin: []string{"bridge", "sentinel", "steward", "oracle"},
42
+ Supervision: "#alerts",
43
+ Ephemeral: true,
44
+ TTL: config.Duration{Duration: 168 * time.Hour},
45
+ },
46
+ {
47
+ Name: "experiment",
48
+ Prefix: "experiment.",
49
+ },
50
+ },
51
+ })
52
+}
53
+
54
+func TestPolicyMatch(t *testing.T) {
55
+ p := testPolicy()
56
+
57
+ cases := []struct {
58
+ channel string
59
+ wantType string
60
+ }{
61
+ {"#task.gh-42", "task"},
62
+ {"#task.JIRA-99", "task"},
63
+ {"#sprint.2026-q2", "sprint"},
64
+ {"#incident.p1", "incident"},
65
+ {"#experiment.llm-v3", "experimenment"},
66
+ {"#gene // no m // prefix must match exactly (task. not task)
67
+ }
68
+
69
+ for _, tc := range cases {
70
+ t.Run(tc.channel, func(t *testing.T) {
71
+ got := p.TypeName(tc.channel)
72
+ if got != tc.wantType {
73
+ t.Errorf("TypeName(%q) = %q, want %q", tc.channel, got, tc.wantType)
74
+ }
75
+ })
76
+ }
77
+}
78
+
79
+func TestPolicyAutojoinFor(t *testing.T) {
80
+ p := testPolicy()
81
+
82
+ cases := []struct {
83
+ channel string
84
+ wantBots []string
85
+ }{
86
+ {"#task.gh-42", []string{"bridge", "scribe"}},
87
+ {"#sprint.2026-q2", []string{"bridge", "oracle", "herald"}},
88
+ {"#incident.p1", []string{"bridge", "sentinel", "steward", "oracle"}},
89
+ {"#general", []string{"bridge", "oracle", "scribe"}}, // static channel
90
+ {"#alerts", []string{"bridge", "sentinel", "steward"}}, // static channel
91
+ {"#unknown", nil},
92
+ {"#experiment.llm-v3", nil}, // type exists but no autojoin configured
93
+ }
94
+
95
+ for _, tc := range cases {
96
+ t.Run(tc.channel, func(t *testing.T) {
97
+ got := p.AutojoinFor(tc.channel)
98
+ if len(got) != len(tc.wantBots) {
99
+ t.Fatalf("AutojoinFor(%q) = %v, want %v", tc.channel, got, tc.wantBots)
100
+ }
101
+ for i, nick := range tc.wantBots {
102
+ if got[i] != nick {
103
+ t.Errorf("AutojoinFor(%q)[%d] = %q, want %q", tc.channel, i, got[i], nick)
104
+ }
105
+ }
106
+ })
107
+ }
108
+}
109
+
110
+func TestPolicySupervisionFor(t *testing.T) {
111
+ p := testPolicy()
112
+
113
+ cases := []struct {
114
+ ch
--- a/internal/topology/policy_test.go
+++ b/internal/topology/policy_test.go
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/topology/policy_test.go
+++ b/internal/topology/policy_test.go
@@ -0,0 +1,114 @@
1 package topology
2
3 import (
4 "testing"
5 "time"
6
7 "github.com/conflicthq/scuttlebot/internal/config"
8 )
9
10 func testPolicy() *Policy {
11 return NewPolicy(config.TopologyConfig{
12 Channels: []config.StaticChannelConfig{
13 {
14 Name: "#general",
15 Topic: "Fleet coordination",
16 Autojoin: []string{"bridge", "oracle", "scribe"},
17 },
18 {
19 Name: "#alerts",
20 Autojoin: []string{"bridge", "sentinel", "steward"},
21 },
22 },
23 Types: []config.ChannelTypeConfig{
24 {
25 Name: "task",
26 Prefix: "task.",
27 Autojoin: []string{"bridge", "scribe"},
28 Supervision: "#general",
29 Ephemeral: true,
30 TTL: config.Duration{Duration: 72 * time.Hour},
31 },
32 {
33 Name: "sprint",
34 Prefix: "sprint.",
35 Autojoin: []string{"bridge", "oracle", "herald"},
36 Supervision: "",
37 },
38 {
39 Name: "incident",
40 Prefix: "incident.",
41 Autojoin: []string{"bridge", "sentinel", "steward", "oracle"},
42 Supervision: "#alerts",
43 Ephemeral: true,
44 TTL: config.Duration{Duration: 168 * time.Hour},
45 },
46 {
47 Name: "experiment",
48 Prefix: "experiment.",
49 },
50 },
51 })
52 }
53
54 func TestPolicyMatch(t *testing.T) {
55 p := testPolicy()
56
57 cases := []struct {
58 channel string
59 wantType string
60 }{
61 {"#task.gh-42", "task"},
62 {"#task.JIRA-99", "task"},
63 {"#sprint.2026-q2", "sprint"},
64 {"#incident.p1", "incident"},
65 {"#experiment.llm-v3", "experimenment"},
66 {"#gene // no m // prefix must match exactly (task. not task)
67 }
68
69 for _, tc := range cases {
70 t.Run(tc.channel, func(t *testing.T) {
71 got := p.TypeName(tc.channel)
72 if got != tc.wantType {
73 t.Errorf("TypeName(%q) = %q, want %q", tc.channel, got, tc.wantType)
74 }
75 })
76 }
77 }
78
79 func TestPolicyAutojoinFor(t *testing.T) {
80 p := testPolicy()
81
82 cases := []struct {
83 channel string
84 wantBots []string
85 }{
86 {"#task.gh-42", []string{"bridge", "scribe"}},
87 {"#sprint.2026-q2", []string{"bridge", "oracle", "herald"}},
88 {"#incident.p1", []string{"bridge", "sentinel", "steward", "oracle"}},
89 {"#general", []string{"bridge", "oracle", "scribe"}}, // static channel
90 {"#alerts", []string{"bridge", "sentinel", "steward"}}, // static channel
91 {"#unknown", nil},
92 {"#experiment.llm-v3", nil}, // type exists but no autojoin configured
93 }
94
95 for _, tc := range cases {
96 t.Run(tc.channel, func(t *testing.T) {
97 got := p.AutojoinFor(tc.channel)
98 if len(got) != len(tc.wantBots) {
99 t.Fatalf("AutojoinFor(%q) = %v, want %v", tc.channel, got, tc.wantBots)
100 }
101 for i, nick := range tc.wantBots {
102 if got[i] != nick {
103 t.Errorf("AutojoinFor(%q)[%d] = %q, want %q", tc.channel, i, got[i], nick)
104 }
105 }
106 })
107 }
108 }
109
110 func TestPolicySupervisionFor(t *testing.T) {
111 p := testPolicy()
112
113 cases := []struct {
114 ch

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button