feat: per-topic announce target (move guild+channel from env to topic row)

Operator decision: backend env hard-coding a single guild/channel was
wrong because (a) one Center can host many guilds and (b) one guild
can have many announce channels for different purposes. The
proposing agent now chooses where this topic's lifecycle events go,
passed as create-topic params and stored on the topic row.

Schema migration 002:
- ALTER topics ADD announce_guild_base_url VARCHAR(255) NULL,
                  announce_channel_id     VARCHAR(64)  NULL.
- Both nullable; one-of-two is rejected at POST time; both null =
  topic creator opted out of broadcasts (announcer skips with log).

handlers/topics.go: createTopicBody adds announce_guild_base_url +
announce_channel_id; validates both-or-neither.

fabric/announce.go: rewritten signature. NewAnnouncer takes only
the system api key. PostTopicAnnouncement + PostLifecycleEvent take
a Target {GuildBaseURL, ChannelID} per call. Zero-value Target -> skip.

orchestrator/ticker.go: new helper topicTarget(topic) extracts the
target from the topic row; all broadcasts route through it.

verdict.go: same per-topic target extraction at completion.

config: removed FabricGuildBaseURL, FabricAnnounceChannelID,
FabricBotBearerToken from the Config struct + env reads.
FabricSystemAPIKey env renamed to DIALECTIC_FABRIC_SYSTEM_API_KEY
to disambiguate from the Fabric backend's own
FABRIC_BACKEND_GUILD_SYSTEM_API_KEY (operator: paste the same value
into both - one says "I am the system caller", the other says "I
accept this caller as system").

FABRIC_BOT_BEARER_TOKEN is gone entirely. The upgraded Guild
ApiKeyGuard accepts x-fabric-system-key alone for announce posts;
no per-user Bearer needed. Pairs with the matching change on
nav/Fabric.Backend.Guild commit 985b06a.
This commit is contained in:
h z
2026-05-23 17:53:30 +01:00
parent b2a0cac460
commit a43ff2de62
10 changed files with 190 additions and 161 deletions

View File

@@ -1,19 +1,15 @@
// Package fabric provides an HTTP client for posting system messages
// to a Fabric Guild's announce-type channel.
//
// Auth model: Dialectic backend holds a system api key in
// FABRIC_SYSTEM_API_KEY env. It POSTs to
// `<guildBaseUrl>/api/channels/<channelId>/messages` with both:
// - `Authorization: Bearer <fabric-system-account-token>` (the
// "user" that posts; needs to be a Fabric Center user that owns
// the channel)
// - `x-fabric-system-key: <SYSTEM_API_KEY>` (the new header added
// in Phase 1 that gates announce-channel POSTs)
// As of Phase 3.5 the target (guildBaseUrl + channelID) is per-call
// rather than backend-env: each topic stores its own announce target,
// chosen by the proposing agent at create time. Only the system api
// key stays in backend env (DIALECTIC_FABRIC_SYSTEM_API_KEY) since it
// authorizes ANY announce POST regardless of channel.
//
// Phase 2D ships this as an OPTIONAL coupling: if any of the three
// config values is unset, the announcer is a no-op (logs the would-be
// post and returns nil). This lets the orchestrator pipeline land + run
// in environments where the Fabric coupling isn't configured yet.
// FABRIC_BOT_BEARER_TOKEN is gone — the upgraded Fabric.Backend.Guild
// ApiKeyGuard accepts x-fabric-system-key alone (no per-user Bearer
// required) for announce-channel posts.
package fabric
import (
@@ -27,62 +23,74 @@ import (
"time"
)
type AnnounceConfig struct {
GuildBaseURL string // e.g. https://fabric-api.hangman-lab.top
ChannelID string // the announce-type channel uuid
SystemAPIKey string // x-fabric-system-key value
BotBearerToken string // Authorization: Bearer <token> — Fabric user posting; same
// user is the "channel author" the backend records
RequestTimeout time.Duration
}
type Announcer struct {
cfg AnnounceConfig
client *http.Client
systemAPIKey string
requestTimeout time.Duration
client *http.Client
}
func NewAnnouncer(cfg AnnounceConfig) *Announcer {
if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = 5 * time.Second
}
func NewAnnouncer(systemAPIKey string) *Announcer {
timeout := 5 * time.Second
return &Announcer{
cfg: cfg,
client: &http.Client{Timeout: cfg.RequestTimeout},
systemAPIKey: systemAPIKey,
requestTimeout: timeout,
client: &http.Client{Timeout: timeout},
}
}
// Enabled returns true iff every required Announcer config field is set.
// Orchestrator checks this before each broadcast and silently skips when
// false (with a one-time startup log).
func (a *Announcer) Enabled() bool {
return a.cfg.GuildBaseURL != "" && a.cfg.ChannelID != "" &&
a.cfg.SystemAPIKey != "" && a.cfg.BotBearerToken != ""
// Enabled returns true iff the system api key is configured (without
// it, no announce POST can authenticate).
func (a *Announcer) Enabled() bool { return a.systemAPIKey != "" }
// Target identifies one announce post destination. Both fields must
// be non-empty; either empty -> Post* returns a "skipped" no-op.
type Target struct {
GuildBaseURL string
ChannelID string
}
// PostTopicAnnouncement posts a one-line system message describing a
// new topic that's ready for signup. Best-effort: log + nil on transport
// errors so a Fabric outage doesn't block topic transitions.
func (a *Announcer) PostTopicAnnouncement(ctx context.Context, topicID, title, summary string,
signupOpen, signupClose, debateStart, debateEnd time.Time, verdictSchemaID string) error {
// PostTopicAnnouncement: first signup-open broadcast.
// PostLifecycleEvent: subsequent state transitions.
//
// Both best-effort: log + nil on transport / target-misconfigured
// errors so the orchestrator transition isn't blocked by a Fabric
// outage or an opted-out topic.
func (a *Announcer) PostTopicAnnouncement(ctx context.Context, t Target,
topicID, title, summary string,
signupOpen, signupClose, debateStart, debateEnd time.Time,
verdictSchemaID string) error {
body := formatAnnouncement(topicID, title, summary,
signupOpen, signupClose, debateStart, debateEnd, verdictSchemaID)
return a.post(ctx, t, topicID, "signup_open", body)
}
func (a *Announcer) PostLifecycleEvent(ctx context.Context, t Target,
topicID, title, kind, summary string) error {
body := fmt.Sprintf("📣 **[%s]** %s [%s]\n%s", kind, title, topicID, summary)
return a.post(ctx, t, topicID, kind, body)
}
func (a *Announcer) post(ctx context.Context, t Target, topicID, kind, content string) error {
if !a.Enabled() {
log.Printf("announce: skipped (fabric coupling not configured) topic=%s", topicID)
log.Printf("announce: skipped topic=%s kind=%s — DIALECTIC_FABRIC_SYSTEM_API_KEY unset", topicID, kind)
return nil
}
if t.GuildBaseURL == "" || t.ChannelID == "" {
log.Printf("announce: skipped topic=%s kind=%s — no announce target on topic (creator opted out)", topicID, kind)
return nil
}
body := map[string]any{
"content": formatAnnouncement(topicID, title, summary, signupOpen, signupClose, debateStart, debateEnd, verdictSchemaID),
}
raw, _ := json.Marshal(body)
body, _ := json.Marshal(map[string]any{"content": content})
url := fmt.Sprintf("%s/api/channels/%s/messages",
trimRightSlash(a.cfg.GuildBaseURL), a.cfg.ChannelID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
trimRightSlash(t.GuildBaseURL), t.ChannelID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("content-type", "application/json")
req.Header.Set("authorization", "Bearer "+a.cfg.BotBearerToken)
req.Header.Set("x-fabric-system-key", a.cfg.SystemAPIKey)
req.Header.Set("x-fabric-system-key", a.systemAPIKey)
resp, err := a.client.Do(req)
if err != nil {
@@ -92,7 +100,7 @@ func (a *Announcer) PostTopicAnnouncement(ctx context.Context, topicID, title, s
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
log.Printf("announce: POST %s -> %d body=%s", url, resp.StatusCode, string(b))
log.Printf("announce: POST %s kind=%s -> %d body=%s", url, kind, resp.StatusCode, string(b))
return fmt.Errorf("announce post: status %d", resp.StatusCode)
}
return nil
@@ -115,47 +123,6 @@ func formatAnnouncement(id, title, summary string,
)
}
// PostLifecycleEvent broadcasts a non-signup state change to the
// announce channel. The agents' Fabric inbound + busy-discard
// (Phase 1) handles per-agent wakeup gating — no per-agent fanout
// needed from this side. Sole purpose is to let agents see "your
// debate moved to X" without polling.
//
// Currently called for: signup_closed (with allocated camps),
// cancelled, debating-start, completed. Format is plain text with the
// topic_id so agents' workflows can parse and route.
func (a *Announcer) PostLifecycleEvent(ctx context.Context, topicID, title, kind, summary string) error {
if !a.Enabled() {
log.Printf("announce: lifecycle skipped (fabric coupling not configured) topic=%s kind=%s", topicID, kind)
return nil
}
body := map[string]any{
"content": fmt.Sprintf("📣 **[%s]** %s [%s]\n%s", kind, title, topicID, summary),
}
raw, _ := json.Marshal(body)
url := fmt.Sprintf("%s/api/channels/%s/messages",
trimRightSlash(a.cfg.GuildBaseURL), a.cfg.ChannelID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return err
}
req.Header.Set("content-type", "application/json")
req.Header.Set("authorization", "Bearer "+a.cfg.BotBearerToken)
req.Header.Set("x-fabric-system-key", a.cfg.SystemAPIKey)
resp, err := a.client.Do(req)
if err != nil {
log.Printf("announce: lifecycle POST %s failed: %v", url, err)
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
log.Printf("announce: lifecycle POST %s -> %d body=%s", url, resp.StatusCode, string(b))
return fmt.Errorf("lifecycle post: status %d", resp.StatusCode)
}
return nil
}
func trimRightSlash(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]