Files
CHORUS/docs/SEQTHINK-AGE-WRAPPER-IMPLEMENTATION.md
anthonyrawlins 3ce9811826 Implement Beat 1: Sequential Thinking Age-Encrypted Wrapper (Skeleton)
This commit completes Beat 1 of the SequentialThinkingForCHORUS implementation,
providing a functional plaintext skeleton for the age-encrypted wrapper.

## Deliverables

### 1. Main Wrapper Entry Point
- `cmd/seqthink-wrapper/main.go`: HTTP server on :8443
- Configuration loading from environment variables
- Graceful shutdown handling
- MCP server readiness checking with timeout

### 2. MCP Client Package
- `pkg/seqthink/mcpclient/client.go`: HTTP client for MCP server
- Communicates with MCP server on localhost:8000
- Health check endpoint
- Tool call endpoint with 120s timeout

### 3. Proxy Server Package
- `pkg/seqthink/proxy/server.go`: HTTP handlers for wrapper
- Health and readiness endpoints
- Tool call proxy (plaintext for Beat 1)
- SSE endpoint placeholder
- Metrics endpoint integration

### 4. Observability Package
- `pkg/seqthink/observability/logger.go`: Structured logging with zerolog
- `pkg/seqthink/observability/metrics.go`: Prometheus metrics
- Counters for requests, errors, decrypt/encrypt failures, policy denials
- Request duration histogram

### 5. Docker Infrastructure
- `deploy/seqthink/Dockerfile`: Multi-stage build
- `deploy/seqthink/entrypoint.sh`: Startup orchestration
- `deploy/seqthink/mcp_stub.py`: Minimal MCP server for testing

### 6. Build System Integration
- Updated `Makefile` with `build-seqthink` target
- Uses GOWORK=off and -mod=mod for clean builds
- `docker-seqthink` target for container builds

## Testing

Successfully builds with:
```
make build-seqthink
```

Binary successfully starts and waits for MCP server connection.

## Next Steps

Beat 2 will add:
- Age encryption/decryption (pkg/seqthink/ageio)
- Content-Type: application/age enforcement
- SSE streaming with encrypted frames
- Golden tests for crypto round-trips

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 08:35:43 +11:00

28 KiB

Sequential Thinking Age-Encrypted Wrapper - Implementation Guide

Date: 2025-10-13 Status: Ready for Implementation Project: SequentialThinkingForCHORUS Priority: High - Blocking agent intelligence improvements


Executive Summary

This document provides the complete implementation plan for the age-encrypted Sequential Thinking MCP wrapper as specified in the SequentialThinkingForCHORUS issue #1. This wrapper enables secure, production-grade deployment of Sequential Thinking capabilities while maintaining confidentiality through end-to-end encryption.

Two-Part Solution:

  1. Part A: Build the seqthink-age container (age-encrypted wrapper)
  2. Part B: Integrate CHORUS agents with the encrypted wrapper

Part A: seqthink-age Container Implementation

Repository Structure

seqthink-age/
├── cmd/
│   └── agewrap/
│       └── main.go                    # Wrapper entry point
├── pkg/
│   ├── ageio/
│   │   ├── crypto.go                  # SealFrame/OpenFrame + gzip
│   │   ├── crypto_test.go
│   │   └── testdata/                  # Golden test vectors
│   ├── policy/
│   │   ├── kaching.go                 # JWT verification
│   │   ├── scope.go                   # Scope checking
│   │   └── policy_test.go
│   ├── proxy/
│   │   ├── handlers.go                # HTTP handlers
│   │   ├── sse.go                     # SSE streaming
│   │   ├── health.go                  # Health checks
│   │   └── proxy_test.go
│   ├── mcpclient/
│   │   ├── client.go                  # Loopback MCP caller
│   │   └── client_test.go
│   └── observability/
│       ├── metrics.go                 # Prometheus metrics
│       ├── logging.go                 # Structured logging
│       └── pprof.go                   # Optional profiling
├── deploy/
│   ├── Dockerfile                     # Multi-stage build
│   ├── entrypoint.sh                  # Process orchestration
│   └── compose.example.yml            # Local dev stack
├── secrets/                           # .gitignored
│   ├── age_identity.key.example
│   └── age_recipients.txt.example
├── test/
│   ├── integration/
│   │   └── e2e_test.go
│   └── fixtures/
│       └── jwt_samples.json
├── docs/
│   ├── API.md                         # API documentation
│   ├── SECURITY.md                    # Security model
│   └── WHOOSH_INTEGRATION.md          # WHOOSH client guide
├── .github/
│   └── workflows/
│       └── ci.yml                     # Build, test, sign, push
├── Makefile
├── go.mod
├── go.sum
├── README.md
└── LICENSE

Beat 1: Skeleton & Plaintext Correct

Implementation Tasks

1.1 Repository Setup

# Create repository structure
mkdir -p seqthink-age/{cmd/agewrap,pkg/{ageio,policy,proxy,mcpclient,observability},deploy,test/{integration,fixtures},docs,.github/workflows}

# Initialize Go module
cd seqthink-age
go mod init github.com/chorus-services/seqthink-age

# Add dependencies
go get github.com/gorilla/mux
go get github.com/rs/zerolog
go get github.com/prometheus/client_golang/prometheus
go get filippo.io/age
go get github.com/golang-jwt/jwt/v5

1.2 Main Entry Point

File: cmd/agewrap/main.go

package main

import (
    "context"
    "fmt"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/chorus-services/seqthink-age/pkg/mcpclient"
    "github.com/chorus-services/seqthink-age/pkg/proxy"
    "github.com/chorus-services/seqthink-age/pkg/observability"
    "github.com/rs/zerolog/log"
)

type Config struct {
    Port             string
    MCPLocalURL      string
    LogLevel         string
    MaxBodyMB        int
    HealthTimeout    time.Duration
    ShutdownTimeout  time.Duration
}

func loadConfig() *Config {
    return &Config{
        Port:            getEnv("PORT", "8443"),
        MCPLocalURL:     getEnv("MCP_LOCAL", "http://127.0.0.1:8000"),
        LogLevel:        getEnv("LOG_LEVEL", "info"),
        MaxBodyMB:       getEnvInt("MAX_BODY_MB", 4),
        HealthTimeout:   5 * time.Second,
        ShutdownTimeout: 30 * time.Second,
    }
}

func main() {
    cfg := loadConfig()

    // Initialize observability
    observability.InitLogger(cfg.LogLevel)
    metrics := observability.InitMetrics()

    log.Info().
        Str("port", cfg.Port).
        Str("mcp_url", cfg.MCPLocalURL).
        Msg("Starting seqthink-age wrapper")

    // Create MCP client
    mcpClient := mcpclient.New(cfg.MCPLocalURL)

    // Wait for MCP server to be ready
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := waitForMCP(ctx, mcpClient); err != nil {
        log.Fatal().Err(err).Msg("MCP server not ready")
    }

    log.Info().Msg("✅ MCP server ready")

    // Create proxy server
    proxyServer := proxy.NewServer(proxy.ServerConfig{
        MCPClient:  mcpClient,
        Metrics:    metrics,
        MaxBodyMB:  cfg.MaxBodyMB,
    })

    // Setup HTTP server
    srv := &http.Server{
        Addr:         ":" + cfg.Port,
        Handler:      proxyServer.Handler(),
        ReadTimeout:  30 * time.Second,
        WriteTimeout: 90 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    // Start server in goroutine
    go func() {
        log.Info().Str("addr", srv.Addr).Msg("🚀 Wrapper listening")
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal().Err(err).Msg("HTTP server failed")
        }
    }()

    // Wait for shutdown signal
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
    <-sigChan

    log.Info().Msg("🛑 Shutting down gracefully...")

    // Graceful shutdown
    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
    defer shutdownCancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Error().Err(err).Msg("Shutdown error")
    }

    log.Info().Msg("✅ Shutdown complete")
}

func waitForMCP(ctx context.Context, client *mcpclient.Client) error {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return fmt.Errorf("timeout waiting for MCP server")
        case <-ticker.C:
            if err := client.Health(ctx); err == nil {
                return nil
            }
            log.Debug().Msg("Waiting for MCP server...")
        }
    }
}

func getEnv(key, defaultVal string) string {
    if val := os.Getenv(key); val != "" {
        return val
    }
    return defaultVal
}

func getEnvInt(key string, defaultVal int) int {
    // Implementation
    return defaultVal
}

1.3 MCP Client

File: pkg/mcpclient/client.go

package mcpclient

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
)

type Client struct {
    baseURL    string
    httpClient *http.Client
}

type ToolRequest struct {
    Tool    string                 `json:"tool"`
    Payload map[string]interface{} `json:"payload"`
}

type ToolResponse struct {
    Result interface{} `json:"result"`
    Error  string      `json:"error,omitempty"`
}

func New(baseURL string) *Client {
    return &Client{
        baseURL: baseURL,
        httpClient: &http.Client{
            Timeout: 60 * time.Second,
        },
    }
}

func (c *Client) Health(ctx context.Context) error {
    req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/health", nil)
    if err != nil {
        return err
    }

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("health check failed: status %d", resp.StatusCode)
    }

    return nil
}

func (c *Client) CallTool(ctx context.Context, req *ToolRequest) (*ToolResponse, error) {
    jsonData, err := json.Marshal(req)
    if err != nil {
        return nil, fmt.Errorf("marshal request: %w", err)
    }

    httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/mcp/tool", bytes.NewReader(jsonData))
    if err != nil {
        return nil, err
    }

    httpReq.Header.Set("Content-Type", "application/json")

    resp, err := c.httpClient.Do(httpReq)
    if err != nil {
        return nil, fmt.Errorf("http request: %w", err)
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, fmt.Errorf("read response: %w", err)
    }

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("tool call failed: status %d, body: %s", resp.StatusCode, string(body))
    }

    var toolResp ToolResponse
    if err := json.Unmarshal(body, &toolResp); err != nil {
        return nil, fmt.Errorf("unmarshal response: %w", err)
    }

    return &toolResp, nil
}

1.4 Proxy Handlers (Plaintext Phase)

File: pkg/proxy/server.go

package proxy

import (
    "encoding/json"
    "io"
    "net/http"

    "github.com/chorus-services/seqthink-age/pkg/mcpclient"
    "github.com/chorus-services/seqthink-age/pkg/observability"
    "github.com/gorilla/mux"
    "github.com/rs/zerolog/log"
)

type ServerConfig struct {
    MCPClient *mcpclient.Client
    Metrics   *observability.Metrics
    MaxBodyMB int
}

type Server struct {
    config ServerConfig
    router *mux.Router
}

func NewServer(cfg ServerConfig) *Server {
    s := &Server{
        config: cfg,
        router: mux.NewRouter(),
    }

    s.setupRoutes()
    return s
}

func (s *Server) Handler() http.Handler {
    return s.router
}

func (s *Server) setupRoutes() {
    // Health endpoints
    s.router.HandleFunc("/health", s.handleHealth).Methods("GET")
    s.router.HandleFunc("/ready", s.handleReady).Methods("GET")

    // MCP proxy endpoints
    s.router.HandleFunc("/mcp/tool", s.handleToolCall).Methods("POST")
    s.router.HandleFunc("/mcp/sse", s.handleSSE).Methods("GET")

    // Metrics (optional)
    if s.config.Metrics != nil {
        s.router.Handle("/metrics", s.config.Metrics.Handler())
    }
}

func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // Check MCP server health
    if err := s.config.MCPClient.Health(ctx); err != nil {
        log.Warn().Err(err).Msg("MCP health check failed")
        http.Error(w, "MCP server unhealthy", http.StatusServiceUnavailable)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "ready"})
}

func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // Limit body size
    r.Body = http.MaxBytesReader(w, r.Body, int64(s.config.MaxBodyMB*1024*1024))

    // Read request body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        log.Warn().Err(err).Msg("Failed to read request body")
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }

    // Parse as MCP tool request
    var toolReq mcpclient.ToolRequest
    if err := json.Unmarshal(body, &toolReq); err != nil {
        log.Warn().Err(err).Msg("Failed to parse tool request")
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    // Call MCP server
    resp, err := s.config.MCPClient.CallTool(ctx, &toolReq)
    if err != nil {
        log.Error().Err(err).Msg("MCP tool call failed")
        http.Error(w, "Tool execution failed", http.StatusInternalServerError)
        return
    }

    // Return response
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(resp)
}

func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
    // SSE implementation (placeholder for Beat 1)
    http.Error(w, "SSE not yet implemented", http.StatusNotImplemented)
}

1.5 Dockerfile & Entrypoint

File: deploy/Dockerfile

# Build stage
FROM golang:1.22-alpine AS builder

WORKDIR /build

# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download

# Copy source
COPY . .

# Build wrapper
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-w -s" \
    -o agewrap \
    ./cmd/agewrap

# Runtime stage
FROM debian:stable-slim

# Install MCP server (placeholder - adjust based on actual MCP distribution)
RUN apt-get update && apt-get install -y \
    ca-certificates \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Copy wrapper binary
COPY --from=builder /build/agewrap /usr/local/bin/agewrap

# Copy entrypoint
COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# Create non-root user
RUN groupadd -g 1000 seqthink && \
    useradd -u 1000 -g seqthink -s /bin/bash -d /home/seqthink -m seqthink

# Runtime directories
RUN mkdir -p /run/secrets /tmp/seqthink && \
    chown -R seqthink:seqthink /tmp/seqthink

USER seqthink

EXPOSE 8443

ENTRYPOINT ["/entrypoint.sh"]

File: deploy/entrypoint.sh

#!/bin/bash
set -euo pipefail

echo "🚀 Starting Sequential Thinking Wrapper"

# Start MCP server on loopback
echo "📡 Starting MCP server on 127.0.0.1:8000..."
sequentialthinking --transport=http --port=8000 --bind=127.0.0.1 &
MCP_PID=$!

# Wait for MCP to be ready
echo "⏳ Waiting for MCP server..."
for i in {1..30}; do
    if curl -sf http://127.0.0.1:8000/health > /dev/null 2>&1; then
        echo "✅ MCP server ready"
        break
    fi
    if [ $i -eq 30 ]; then
        echo "❌ MCP server failed to start"
        exit 1
    fi
    sleep 1
done

# Start age wrapper
echo "🔐 Starting age wrapper on :${PORT:-8443}..."
exec agewrap

1.6 Makefile

File: Makefile

.PHONY: all build test lint clean image

# Variables
IMAGE_NAME ?= anthonyrawlins/seqthink-age
IMAGE_TAG ?= latest
GOBIN ?= $(shell go env GOPATH)/bin

all: lint test build

build:
	@echo "Building agewrap..."
	go build -o bin/agewrap ./cmd/agewrap

test:
	@echo "Running tests..."
	go test -v -race -cover ./...

lint:
	@echo "Running linters..."
	golangci-lint run ./...

clean:
	@echo "Cleaning..."
	rm -rf bin/

image:
	@echo "Building Docker image..."
	docker build -f deploy/Dockerfile -t $(IMAGE_NAME):$(IMAGE_TAG) .

image-push:
	@echo "Pushing image..."
	docker push $(IMAGE_NAME):$(IMAGE_TAG)

run-local:
	@echo "Running locally..."
	MCP_LOCAL=http://127.0.0.1:8000 PORT=8443 go run ./cmd/agewrap

compose-up:
	docker-compose -f deploy/compose.example.yml up --build

compose-down:
	docker-compose -f deploy/compose.example.yml down

Beat 1 Acceptance Criteria

  • make build compiles successfully
  • docker build creates image
  • Container starts MCP server on loopback
  • Container starts wrapper on :8443
  • /health endpoint returns 200
  • POST to /mcp/tool with plaintext JSON returns tool result
  • Logs are structured JSON
  • Graceful shutdown works

Beat 2: Crypto Envelope (Age + Gzip)

Implementation Tasks

2.1 Age Crypto Implementation

File: pkg/ageio/crypto.go

package ageio

import (
    "bytes"
    "compress/gzip"
    "fmt"
    "io"

    "filippo.io/age"
)

// SealFrame encrypts and compresses plaintext
func SealFrame(plaintext []byte, recipients []age.Recipient) ([]byte, error) {
    // 1. Gzip compress
    var compressed bytes.Buffer
    gzipWriter := gzip.NewWriter(&compressed)
    if _, err := gzipWriter.Write(plaintext); err != nil {
        return nil, fmt.Errorf("gzip write: %w", err)
    }
    if err := gzipWriter.Close(); err != nil {
        return nil, fmt.Errorf("gzip close: %w", err)
    }

    // 2. Age encrypt
    var encrypted bytes.Buffer
    encWriter, err := age.Encrypt(&encrypted, recipients...)
    if err != nil {
        return nil, fmt.Errorf("age encrypt: %w", err)
    }

    if _, err := encWriter.Write(compressed.Bytes()); err != nil {
        return nil, fmt.Errorf("encrypt write: %w", err)
    }

    if err := encWriter.Close(); err != nil {
        return nil, fmt.Errorf("encrypt close: %w", err)
    }

    return encrypted.Bytes(), nil
}

// OpenFrame decrypts and decompresses ciphertext
func OpenFrame(ciphertext []byte, identities []age.Identity) ([]byte, error) {
    // 1. Age decrypt
    decReader, err := age.Decrypt(bytes.NewReader(ciphertext), identities...)
    if err != nil {
        return nil, fmt.Errorf("age decrypt: %w", err)
    }

    // 2. Gunzip decompress
    gzipReader, err := gzip.NewReader(decReader)
    if err != nil {
        return nil, fmt.Errorf("gzip reader: %w", err)
    }
    defer gzipReader.Close()

    plaintext, err := io.ReadAll(gzipReader)
    if err != nil {
        return nil, fmt.Errorf("decompress: %w", err)
    }

    return plaintext, nil
}

File: pkg/ageio/keys.go

package ageio

import (
    "bufio"
    "fmt"
    "os"
    "strings"

    "filippo.io/age"
)

// LoadIdentities loads age identities from file
func LoadIdentities(path string) ([]age.Identity, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("open identity file: %w", err)
    }
    defer f.Close()

    return age.ParseIdentities(f)
}

// LoadRecipients loads age recipients from file
func LoadRecipients(path string) ([]age.Recipient, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("open recipients file: %w", err)
    }
    defer f.Close()

    var recipients []age.Recipient
    scanner := bufio.NewScanner(f)
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        if line == "" || strings.HasPrefix(line, "#") {
            continue
        }

        recipient, err := age.ParseX25519Recipient(line)
        if err != nil {
            return nil, fmt.Errorf("parse recipient: %w", err)
        }

        recipients = append(recipients, recipient)
    }

    if err := scanner.Err(); err != nil {
        return nil, fmt.Errorf("scan recipients: %w", err)
    }

    return recipients, nil
}

2.2 Update Proxy for Encryption

File: pkg/proxy/server.go (additions)

type Server struct {
    config     ServerConfig
    router     *mux.Router
    identities []age.Identity  // NEW
    recipients []age.Recipient // NEW
}

func NewServer(cfg ServerConfig) (*Server, error) {
    s := &Server{
        config: cfg,
        router: mux.NewRouter(),
    }

    // Load age keys if configured
    if cfg.AgeIdentPath != "" {
        identities, err := ageio.LoadIdentities(cfg.AgeIdentPath)
        if err != nil {
            return nil, fmt.Errorf("load identities: %w", err)
        }
        s.identities = identities
    }

    if cfg.AgeRecipsPath != "" {
        recipients, err := ageio.LoadRecipients(cfg.AgeRecipsPath)
        if err != nil {
            return nil, fmt.Errorf("load recipients: %w", err)
        }
        s.recipients = recipients
    }

    s.setupRoutes()
    return s, nil
}

func (s *Server) handleToolCall(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // Check content type if encryption is enabled
    if s.identities != nil {
        contentType := r.Header.Get("Content-Type")
        if contentType != "application/age" {
            http.Error(w, "Content-Type must be application/age", http.StatusUnsupportedMediaType)
            return
        }
    }

    // Read encrypted body
    r.Body = http.MaxBytesReader(w, r.Body, int64(s.config.MaxBodyMB*1024*1024))
    encryptedBody, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read body", http.StatusBadRequest)
        return
    }

    // Decrypt if encryption enabled
    var plaintext []byte
    if s.identities != nil {
        plaintext, err = ageio.OpenFrame(encryptedBody, s.identities)
        if err != nil {
            log.Error().Err(err).Msg("Decryption failed")
            s.config.Metrics.RecordDecryptFailure()
            http.Error(w, "Decryption failed", http.StatusBadRequest)
            return
        }
    } else {
        plaintext = encryptedBody
    }

    // Parse tool request
    var toolReq mcpclient.ToolRequest
    if err := json.Unmarshal(plaintext, &toolReq); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    // Call MCP
    resp, err := s.config.MCPClient.CallTool(ctx, &toolReq)
    if err != nil {
        http.Error(w, "Tool execution failed", http.StatusInternalServerError)
        return
    }

    // Marshal response
    responseJSON, err := json.Marshal(resp)
    if err != nil {
        http.Error(w, "Failed to marshal response", http.StatusInternalServerError)
        return
    }

    // Encrypt response if encryption enabled
    var responseBody []byte
    if s.recipients != nil {
        responseBody, err = ageio.SealFrame(responseJSON, s.recipients)
        if err != nil {
            log.Error().Err(err).Msg("Encryption failed")
            http.Error(w, "Encryption failed", http.StatusInternalServerError)
            return
        }
        w.Header().Set("Content-Type", "application/age")
    } else {
        responseBody = responseJSON
        w.Header().Set("Content-Type", "application/json")
    }

    w.WriteHeader(http.StatusOK)
    w.Write(responseBody)
}

Beat 2 Acceptance Criteria

  • Age encrypt/decrypt golden tests pass
  • Wrapper enforces Content-Type: application/age
  • End-to-end: encrypted POST → encrypted response
  • Decryption failures return 400 with proper logging
  • Metrics track decrypt successes/failures

[Continuing with Beats 3-6 would follow similar detailed patterns...]


Part B: CHORUS Agent Integration

B.1 CHORUS AI Provider Extension

File: pkg/ai/seqthink_provider.go

package ai

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "time"

    "filippo.io/age"
)

// SeqThinkProvider wraps age-encrypted Sequential Thinking
type SeqThinkProvider struct {
    config      ProviderConfig
    httpClient  *http.Client
    recipients  []age.Recipient
    identities  []age.Identity
    jwt         string // KACHING JWT for policy
}

type SeqThinkRequest struct {
    Tool    string                 `json:"tool"`
    Payload SeqThinkPayload        `json:"payload"`
    Policy  SeqThinkPolicy         `json:"policy"`
    TS      time.Time              `json:"ts"`
    Nonce   string                 `json:"nonce"`
}

type SeqThinkPayload struct {
    Objective string `json:"objective"`
    MaxDepth  int    `json:"max_depth"`
    Reflect   bool   `json:"reflect"`
}

type SeqThinkPolicy struct {
    Caller  string   `json:"caller"`
    License string   `json:"license"` // KACHING JWT
    Scope   []string `json:"scope"`
}

func (p *SeqThinkProvider) ExecuteTask(ctx context.Context, request *TaskRequest) (*TaskResponse, error) {
    startTime := time.Now()

    // Build Sequential Thinking request
    seqReq := SeqThinkRequest{
        Tool: "sequentialthinking",
        Payload: SeqThinkPayload{
            Objective: p.formatTaskAsObjective(request),
            MaxDepth:  10,
            Reflect:   true,
        },
        Policy: SeqThinkPolicy{
            Caller:  request.AgentID,
            License: p.jwt,
            Scope:   []string{"sequentialthinking.run"},
        },
        TS:    time.Now(),
        Nonce: generateNonce(),
    }

    // Marshal to JSON
    plaintext, err := json.Marshal(seqReq)
    if err != nil {
        return nil, fmt.Errorf("marshal request: %w", err)
    }

    // Encrypt with age
    encrypted, err := ageio.SealFrame(plaintext, p.recipients)
    if err != nil {
        return nil, fmt.Errorf("encrypt request: %w", err)
    }

    // POST to wrapper
    httpReq, err := http.NewRequestWithContext(ctx, "POST", p.config.Endpoint+"/mcp/tool", bytes.NewReader(encrypted))
    if err != nil {
        return nil, err
    }

    httpReq.Header.Set("Content-Type", "application/age")

    resp, err := p.httpClient.Do(httpReq)
    if err != nil {
        return nil, fmt.Errorf("http request: %w", err)
    }
    defer resp.Body.Close()

    // Read encrypted response
    encryptedResp, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, fmt.Errorf("read response: %w", err)
    }

    // Decrypt response
    decrypted, err := ageio.OpenFrame(encryptedResp, p.identities)
    if err != nil {
        return nil, fmt.Errorf("decrypt response: %w", err)
    }

    // Parse thinking result
    var thinkingResult struct {
        Thoughts []ThoughtStep `json:"thoughts"`
        Conclusion string      `json:"conclusion"`
    }

    if err := json.Unmarshal(decrypted, &thinkingResult); err != nil {
        return nil, fmt.Errorf("unmarshal response: %w", err)
    }

    // Convert to TaskResponse with structured reasoning
    return &TaskResponse{
        Success:  true,
        TaskID:   request.TaskID,
        AgentID:  request.AgentID,
        Provider: "seqthink",
        Response: thinkingResult.Conclusion,
        StructuredReasoning: &StructuredReasoning{
            Thoughts:   thinkingResult.Thoughts,
            Confidence: 0.9, // Extract from thinking result
        },
        StartTime: startTime,
        EndTime:   time.Now(),
        Duration:  time.Since(startTime),
    }, nil
}

B.2 Configuration

File: config/agent.yaml (add seqthink provider)

ai_providers:
  # Existing providers...

  seqthink:
    type: "seqthink"
    endpoint: "http://seqthink-age:8443"

    # Age encryption
    age_ident_path: "/run/secrets/age_identity.key"
    age_recips_path: "/run/secrets/age_recipients.txt"

    # KACHING authentication
    kaching_jwt_path: "/run/secrets/kaching_jwt"

    # Sequential thinking config
    max_depth: 15
    enable_reflection: true
    min_complexity: 7  # Only use for complex tasks

role_model_mapping:
  roles:
    architect:
      provider: "seqthink"  # Always use Sequential Thinking for architects
      fallback_provider: "resetdata"

Timeline & Resource Estimates

Beat-by-Beat Timeline

Beat Tasks Duration Resources
Beat 1 Skeleton & plaintext 2 days 1 Go dev
Beat 2 Age crypto 2 days 1 Go dev + security review
Beat 3 Policy/JWT 2 days 1 Go dev
Beat 4 Observability 2 days 1 Go dev
Beat 5 CI/CD 1 day 1 DevOps
Beat 6 WHOOSH integration 2 days 1 Go dev
Total 11 days

CHORUS Integration Timeline

Task Duration Dependencies
SeqThink provider 2 days Beat 2 complete
CHORUS config 1 day Provider complete
Integration testing 2 days All complete
Total 5 days

Grand Total: ~16 days (3 weeks with buffer)


Success Criteria

seqthink-age Container

  • All Beat 1-6 acceptance criteria met
  • Image signed with cosign
  • SBOM generated
  • Security scan passes (Trivy)
  • No plaintext visible in tcpdump
  • Load test: 100 rps, p99 < 200ms overhead
  • WHOOSH demo: encrypted plan generation

CHORUS Integration

  • Architect agents use Sequential Thinking
  • Complex tasks (complexity >= 7) trigger SeqThink
  • Structured reasoning stored in TaskResponse
  • Fallback to ResetData works on failures
  • E2E test: council creates task with reasoning trace

Next Steps

  1. Create seqthink-age repository in GITEA
  2. Implement Beat 1 (skeleton)
  3. Implement Beat 2 (encryption)
  4. Continue through Beats 3-6
  5. Integrate with CHORUS agents
  6. Deploy to production swarm

Ready to proceed with implementation?