Files
Dialectic.Backend/internal/httpapi/handlers/topics.go
hzhang 5cf4302d50 refactor(backend): drop backend-driven Fabric broadcast — agent-driven model
The backend no longer broadcasts topic lifecycle events to Fabric. The
new model (per design discussion 2026-05-23 evening):

  - Proposing agent posts a single recruitment fabric-send-message
    immediately after creating a topic (carries topic_id + signup
    window + debate window + title).
  - Downstream agents that decide to participate book a HF on_call
    slot covering the debate window via `hf calendar schedule on_call
    <time> <duration> --job DEBATE-<topic_id>`.
  - HF wakes the agent naturally at slot start; the wake payload
    carries event_data with the DEBATE-<topic_id> code so the agent
    knows why it was woken.
  - The backend stays a pure data + state-machine service and doesn't
    know about Fabric.

Code removed:

  - internal/fabric/announce.go (entire file + empty dir)
  - ticker.go: broadcastLifecycle + broadcastAnnouncement + topicTarget
    helpers; announcer field on Ticker; announce field/arg on NewTicker
  - models/topic.go: AnnounceGuildBaseURL + AnnounceChannelID fields
  - store/topic_store.go: same fields on CreateTopicInput + INSERT
  - handlers/topics.go: same fields on createTopicBody + validation +
    parameter passing to store
  - handlers/verdict.go: announcer field + lifecycle broadcast on
    verdict submit
  - config/config.go: FabricSystemAPIKey field + DIALECTIC_FABRIC_SYSTEM_API_KEY
    env read
  - main.go + routes.go: announcer wiring

Database:

  - migrations/003_drop_topic_announce_target.sql drops the two columns
    added by migration 002. Counterpart commit on the deployment side
    needs DIALECTIC_FABRIC_SYSTEM_API_KEY env removed from
    docker-compose.yml; harmless if left as the backend no longer
    reads it.

Pairs with:
  - Dialectic.OpenclawPlugin: rip announce_* params from
    dialectic_propose_topic (next commit)
  - Fabric.Backend.Center: rip serviceEndpoint field + cli
  - Fabric.Backend.Guild: rip system-key bypass on ApiKeyGuard and
    announce-only-system limit on messaging.controller
  - ClawSkills: rewrite participate-debate + analyze-intel step 4 +
    delete rotate-fabric-system-key workflow
2026-05-23 23:45:22 +01:00

248 lines
7.8 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/auth"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/models"
"git.hangman-lab.top/hzhang/Dialectic.Backend/internal/store"
)
type TopicsHandler struct {
store *store.TopicStore
camps *store.CampStore
}
func NewTopicsHandler(s *store.TopicStore, c *store.CampStore) *TopicsHandler {
return &TopicsHandler{store: s, camps: c}
}
// GET /api/topics?status=...&visibility=...&limit=...&offset=...
//
// Visibility filter is enforced at the auth layer: anonymous callers
// only see visibility=public; authenticated users (CallerUser) see all
// they're entitled to (Phase 2 v1: all; Phase 4 may add per-user ACLs).
// Agent callers (CallerAgent) see all — they're acting as system on
// behalf of the platform.
func (h *TopicsHandler) List(w http.ResponseWriter, r *http.Request) {
caller := auth.FromContext(r.Context())
f := store.ListFilter{
Status: r.URL.Query().Get("status"),
Visibility: r.URL.Query().Get("visibility"),
}
if v, _ := strconv.Atoi(r.URL.Query().Get("limit")); v > 0 {
f.Limit = v
}
if v, _ := strconv.Atoi(r.URL.Query().Get("offset")); v > 0 {
f.Offset = v
}
// Anonymous: force visibility=public.
if caller.Kind == "" {
f.Visibility = string(models.VisibilityPublic)
}
rows, err := h.store.List(r.Context(), f)
if err != nil {
http.Error(w, "list failed: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, map[string]any{"topics": rows, "count": len(rows)})
}
// GET /api/topics/{id}
func (h *TopicsHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
t, err := h.store.GetByID(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "topic not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "get failed: "+err.Error(), http.StatusInternalServerError)
return
}
// Visibility gate: anonymous can only see public; authenticated see all.
caller := auth.FromContext(r.Context())
if caller.Kind == "" && t.Visibility != models.VisibilityPublic {
http.Error(w, "not found", http.StatusNotFound) // 404 not 403 — hide existence
return
}
// Enrich with camps so an agent can locate their own allocation in one
// round-trip. Camps are 0 rows pre-signup_close, 3 rows after — small
// enough that inlining costs nothing. Arguments are deliberately NOT
// inlined (potentially large; agents who need the transcript should
// hit GET /api/topics/{id}/arguments via dialectic_list_arguments).
//
// Backward-compatible: existing callers reading the original Topic
// fields keep working; new callers can read `camps` alongside.
camps, cErr := h.camps.ListByTopic(r.Context(), id)
if cErr != nil {
camps = nil // best-effort; metadata still useful
}
// Marshal Topic into a map and add `camps` as a sibling field rather
// than wrapping under "topic" — that would break every existing
// consumer that reads e.g. response.title / response.status directly.
buf, _ := json.Marshal(t)
out := map[string]any{}
_ = json.Unmarshal(buf, &out)
out["camps"] = camps
writeJSON(w, http.StatusOK, out)
}
type createTopicBody struct {
Title string `json:"title"`
Summary string `json:"summary"`
Visibility string `json:"visibility"` // default "private"
VerdictSchemaID string `json:"verdict_schema_id"` // default "free-form"
SignupOpenAt string `json:"signup_open_at"` // RFC3339
SignupCloseAt string `json:"signup_close_at"`
DebateStartAt string `json:"debate_start_at"`
DebateEndAt string `json:"debate_end_at"`
}
// POST /api/topics
//
// Allowed callers: agent or authenticated user. Anonymous rejected
// (the route wires the auth-required middleware).
func (h *TopicsHandler) Create(w http.ResponseWriter, r *http.Request) {
caller := auth.FromContext(r.Context())
if caller.Kind == "" {
http.Error(w, "auth required", http.StatusUnauthorized)
return
}
var body createTopicBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad body: "+err.Error(), http.StatusBadRequest)
return
}
if body.Title == "" || body.Summary == "" {
http.Error(w, "title and summary required", http.StatusBadRequest)
return
}
if body.Visibility == "" {
body.Visibility = string(models.VisibilityPrivate)
}
if body.VerdictSchemaID == "" {
body.VerdictSchemaID = "free-form"
}
parsed, err := validateLifecycleTimes(body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
created, err := h.store.Create(r.Context(), store.CreateTopicInput{
Title: body.Title,
Summary: body.Summary,
Visibility: models.Visibility(body.Visibility),
VerdictSchemaID: body.VerdictSchemaID,
SignupOpenAt: parsed[0],
SignupCloseAt: parsed[1],
DebateStartAt: parsed[2],
DebateEndAt: parsed[3],
CreatorUserID: caller.ID,
})
if err != nil {
http.Error(w, "create failed: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, created)
}
// validateLifecycleTimes enforces:
//
// signup_open < signup_close <= debate_start < debate_end
//
// All four timestamps must be parsable as RFC3339; failure → 400.
// Returns the parsed times in order [signup_open, signup_close, debate_start, debate_end]
// so the caller can pass typed values to the store (MySQL TIMESTAMP doesn't
// accept ISO8601 strings directly; the driver handles time.Time properly).
func validateLifecycleTimes(b createTopicBody) ([4]time.Time, error) {
type p struct {
name string
raw string
}
parts := []p{
{"signup_open_at", b.SignupOpenAt},
{"signup_close_at", b.SignupCloseAt},
{"debate_start_at", b.DebateStartAt},
{"debate_end_at", b.DebateEndAt},
}
var parsed [4]time.Time
for i, x := range parts {
t, err := time.Parse(time.RFC3339, x.raw)
if err != nil {
return parsed, errors.New(x.name + ": must be RFC3339")
}
parsed[i] = t.UTC()
}
if !parsed[0].Before(parsed[1]) {
return parsed, errors.New("signup_open_at must be before signup_close_at")
}
if parsed[1].After(parsed[2]) {
return parsed, errors.New("signup_close_at must be <= debate_start_at")
}
if !parsed[2].Before(parsed[3]) {
return parsed, errors.New("debate_start_at must be before debate_end_at")
}
return parsed, nil
}
// PUT /api/topics/{id}/visibility — admin-only flip (Phase 2 stub: any
// authenticated user; Phase 4 will check the dialectic-admin role from JWT).
func (h *TopicsHandler) SetVisibility(w http.ResponseWriter, r *http.Request) {
caller := auth.FromContext(r.Context())
if caller.Kind == "" {
http.Error(w, "auth required", http.StatusUnauthorized)
return
}
if caller.Kind == auth.CallerUser && !hasRole(caller, "dialectic-admin") {
http.Error(w, "dialectic-admin role required", http.StatusForbidden)
return
}
id := chi.URLParam(r, "id")
var body struct {
Visibility string `json:"visibility"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
v := models.Visibility(body.Visibility)
if v != models.VisibilityPublic && v != models.VisibilityPrivate {
http.Error(w, "visibility must be public|private", http.StatusBadRequest)
return
}
t, err := h.store.SetVisibility(r.Context(), id, v, caller.ID)
if err != nil {
http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, t)
}
func hasRole(c auth.Caller, role string) bool {
for _, r := range c.Roles {
if r == role {
return true
}
}
return false
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("content-type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}