Commit Graph

4 Commits

Author SHA1 Message Date
a43ff2de62 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.
2026-05-23 17:53:30 +01:00
03b89a547c fix(db,topics): time.Time params for TIMESTAMP + comment-aware SQL split
Two fixes surfaced by sim e2e test (which otherwise passed full
lifecycle: created → signup_open → 3 signups → allocator → debating
→ arguments → verdict gate (409 early, 201 after debate_end_at) →
completed).

1) MySQL TIMESTAMP rejects RFC3339-with-Z strings — passing those as
   sqlx parameters fails with "Incorrect datetime value". Changed
   CreateTopicInput lifecycle fields from string to time.Time; the
   handler parses+UTCs in validateLifecycleTimes (which now returns
   the parsed array along with the validation result) and passes
   time.Time to the store. The mysql driver formats correctly.

2) splitSQL was naive `strings.Split(s, ";")` which split inside
   comments — the 001 migration had a few `--` lines containing `;`
   ("signup_close_at; immutable", etc) which broke. Migration text
   tidied to not use `;` inside comments, AND splitSQL upgraded to
   skip both `-- ...` and `/* ... */` comment regions before splitting.

Sim verified — clean apply on fresh MySQL.
2026-05-23 12:24:13 +01:00
57a1fa1b33 feat: Phase 2D — orchestrator, arguments/verdict endpoints, fabric announce
State machine driver + camp allocator + judge-submitted verdicts +
broadcast hook to Fabric announce channel.

internal/orchestrator/
- allocator.go: pure function implementing the 3-camp rule from the
  2026-05-23 design session — for each camp (pro/con/judge), random
  pick from volunteers; backfill unfilled camps from remaining
  unallocated signups if pool is large enough; <3 final → cancel
  with diagnostic reason. rng injected for test determinism.
- allocator_test.go: 7 tests covering empty/insufficient/single-volunteer
  /multi-volunteer-no-dup/backfill/insufficient-backfill/large-pool
  distinctness invariants. All pass.
- ticker.go: scans every 15s (configurable via ORCHESTRATOR_TICK_INTERVAL),
  drives 3 state transitions atomically:
    created → signup_open (post fabric announcement async)
    signup_open → signup_closed | cancelled (run allocator, write camps)
    signup_closed → debating (open round 0)
  debating → completed is driven by the verdict POST handler (the
  implicit "judging" sub-state is captured by the gate
  status==debating AND now>=debate_end_at). Per-topic transitions
  use SELECT FOR UPDATE so concurrent ticker instances are safe.

internal/fabric/announce.go: HTTP client posting to a Guild announce
channel using x-fabric-system-key header (the Phase 1 gate). Wraps
the formatted topic announcement (title/summary/timing/schema). All
4 config fields required to enable; any missing → no-op with log
(orchestrator runs fine without Fabric coupling for dev).

internal/store/{round,camp,argument,verdict}_store.go: CRUD layer
for the remaining v2 entities. CampStore.WriteAllocation accepts a
tx so the orchestrator can wrap allocator+camps+status into one
atomic transition.

internal/httpapi/handlers/arguments.go:
- POST /api/topics/{id}/arguments — agent posts during debate. Gates:
  agent must be in a camp on this topic; status==debating; content
  nonempty and <=32KB; attached to latest open round.
- GET /api/topics/{id}/arguments — full transcript, visibility-gated.

internal/httpapi/handlers/verdict.go:
- POST /api/topics/{id}/verdict — judge submits. Gates: caller==judge
  camp; status==debating AND now>=debate_end_at; verdict valid JSON;
  rationale required. On success: writes verdicts row (unique on
  topic_id → 409 on dup) and flips topic.status to completed.
- GET /api/topics/{id}/verdict — visibility-gated.

config: 5 new env vars — FABRIC_GUILD_BASE_URL,
FABRIC_ANNOUNCE_CHANNEL_ID, FABRIC_SYSTEM_API_KEY,
FABRIC_BOT_BEARER_TOKEN, ORCHESTRATOR_TICK_INTERVAL.

routes.go: wired new handlers — POST signups/arguments/verdict gated
on agent bearer; GET arguments/verdict on optional-auth chain
(public topics readable anonymously).

main.go: instantiates announcer + ticker; ticker.Run in a goroutine
sharing the lifetime ctx.

go vet + gofmt clean; 7/7 allocator tests pass; 12M static binary.

Next: Phase 2E (deploy to t3 with nginx + CF origin cert) or
Phase 2D.5 (SSE stream for live transcript subscribers).
2026-05-23 12:02:27 +01:00
e706f3d6ef feat: greenfield Go rewrite (Phase 2A + 2B + 2C core)
Replaces the Python v1 (preserved on archive/python-v1 branch).

Stack: Go 1.23 + chi router + sqlx + MySQL 8. Distroless static
container. 12-factor config from env. Embedded SQL migrations.

Schema (internal/db/migrations/001_init.sql):
- topics: 议题 with 4-timestamp lifecycle (signup_open/close +
  debate_start/end), visibility (default private), status state machine,
  verdict_schema FK
- signups: agent self-enrollment with willing_camps (JSON array of
  pro|con|judge), pre_validated audit flag, (topic,agent) unique
- camps: post-allocation lock (one row per topic+camp) — written by
  Phase 2D allocator
- rounds + arguments: chronological debate transcript
- verdicts: judge structured output, one per topic, with token-cost
  trail for future budgeting
- agent_keys + system_keys: peppered sha256 hashes, never raw
- verdict_schemas: seeded with binary, claim-resolution (for
  analyze-intel), policy-recommendation, free-form

Auth (internal/auth):
- AgentAPIKey: real bearer-token middleware against agent_keys;
  best-effort last_used_at touch on success
- OIDCBrowser: Phase 2 stub. Dev mode accepts x-dev-bypass header
  (constant-time compare); prod 401s with a Phase-4-pending hint.
  Real Keycloak JWKS verification lands with the frontend rewrite.

HTTP API (internal/httpapi):
- /api/healthz — db ping + version + uptime
- GET /api/topics — list with status/visibility/limit/offset filters;
  anonymous callers see public only
- GET /api/topics/{id} — visibility-gated (private → 404 hide)
- POST /api/topics — create with RFC3339 lifecycle validation
  (signup_open < signup_close <= debate_start < debate_end)
- PUT /api/topics/{id}/visibility — dialectic-admin role gate
- POST /api/topics/{id}/signups — agent self-enroll; rejects when
  topic.status != signup_open OR outside signup window; idempotent
  upsert per (topic, agent)
- GET /api/topics/{id}/signups — list (any authed caller)

Auth chains:
- optionalAuth: try bearer → try oidc → fall through anonymous
  (handlers branch on Caller.Kind == ""). Uses captureWriter to demote
  inner 401s to "try next" without leaking response bytes.
- requireAnyAuth: chain that 401s if neither succeeds.
- requireAgent: strict bearer-only (signup POST).

Run: `docker compose -f docker-compose.dev.yml up --build`. Migrations
auto-apply on first connect; idempotent on reboot. README documents
env vars, dev bypass usage, agent-key provisioning SQL, and the
Phase 2D/E/3/4/5 roadmap.

go vet clean, gofmt clean, single 11M static binary.
2026-05-23 11:51:55 +01:00