stemedb/sdk/go/steme/client.go
jordan 1ce4004807 feat: Complete Phase 2 (The Cortex) - query, lens, and API layers
This commit adds the read path (Cortex) to complement the write path (Spine):

## Crates
- stemedb-api: HTTP API with axum + utoipa OpenAPI
  - /v1/assert, /v1/query, /v1/epoch, /v1/skeptic, /v1/trace, /v1/audit
  - Metered endpoints with quota enforcement
  - Ed25519 signature verification
- stemedb-lens: Truth resolution lenses
  - RecencyLens, ConsensusLens, ConfidenceLens
  - VoteAwareConsensusLens (Ballot Box pattern)
  - TrustAwareAuthorityLens (The Hive pattern)
  - SkepticLens (conflict analysis)
  - EpochAwareLens (paradigm-safe queries)
- stemedb-query: Query engine with materialized views

## Storage Extensions
- VoteStore: Vote aggregation with cached counts
- TrustRankStore: Agent reputation with decay
- AuditStore: Query audit trail
- IndexStore: SP/P/S index structures
- SupersessionStore: Epoch supersession chains

## SDKs
- sdk/go/steme: Go HTTP client with Ed25519 signing
- sdk/go/adk: ADK-Go tools for AI agents

## Documentation
- Updated CLAUDE.md, architecture.md, roadmap.md
- New ai-lookup entries for all services
- Use case docs for consumer health intelligence
- Arena roadmap for simulation advancement

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 13:22:44 -07:00

397 lines
10 KiB
Go

package steme
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// Client is the StemeDB HTTP API client with automatic signing.
//
// Create a client with NewClient(). All assertions are automatically
// signed using the provided Signer.
type Client struct {
baseURL string
httpClient *http.Client
signer *Signer
}
// NewClient creates a new StemeDB API client.
//
// The baseURL should be the API endpoint (e.g., "http://localhost:3000").
// The signer is used to automatically sign all assertions.
//
// Example:
//
// signer, _ := steme.GenerateSigner()
// client := steme.NewClient("http://localhost:3000", signer)
func NewClient(baseURL string, signer *Signer) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
signer: signer,
}
}
// WithHTTPClient sets a custom http.Client.
//
// This is useful for adding custom timeouts, TLS config, or proxies.
func (c *Client) WithHTTPClient(httpClient *http.Client) *Client {
c.httpClient = httpClient
return c
}
// Assert creates a new assertion in the knowledge graph.
//
// The assertion is automatically signed using the client's signer.
// Returns the content-addressed hash of the created assertion.
//
// Example:
//
// assertion := steme.NewAssertion("Tesla_Inc", "has_revenue").
// WithNumber(96.7).
// WithConfidence(0.95).
// WithSourceHash("0000000000000000000000000000000000000000000000000000000000000000").
// Build()
//
// hash, err := client.Assert(ctx, assertion)
func (c *Client) Assert(ctx context.Context, assertion Assertion) (string, error) {
// Validate before sending (includes source_hash requirement)
if err := assertion.Validate(); err != nil {
return "", err
}
// Sign the assertion
signature, err := c.signAssertion(&assertion)
if err != nil {
return "", fmt.Errorf("steme: failed to sign assertion: %w", err)
}
assertion.Signatures = []SignatureEntry{signature}
// Make the API request
var resp CreateResponse
if err := c.doJSON(ctx, "POST", "/v1/assert", assertion, &resp); err != nil {
return "", err
}
return resp.Hash, nil
}
// Query queries assertions with optional filters and lens-based conflict resolution.
//
// Use QueryBuilder to construct query parameters fluently.
//
// Example:
//
// params := steme.NewQuery().
// WithSubject("Tesla_Inc").
// WithPredicate("has_revenue").
// WithLens(steme.LensConsensus).
// Build()
//
// result, err := client.Query(ctx, params)
func (c *Client) Query(ctx context.Context, params QueryParams) (*QueryResult, error) {
queryURL := "/v1/query"
// Build query parameters
values := url.Values{}
if params.Subject != nil {
values.Add("subject", *params.Subject)
}
if params.Predicate != nil {
values.Add("predicate", *params.Predicate)
}
if params.Lifecycle != nil {
values.Add("lifecycle", string(*params.Lifecycle))
}
if params.Epoch != nil {
values.Add("epoch", *params.Epoch)
}
if params.Lens != nil {
values.Add("lens", string(*params.Lens))
}
if params.Limit > 0 {
values.Add("limit", fmt.Sprintf("%d", params.Limit))
}
if len(values) > 0 {
queryURL += "?" + values.Encode()
}
var result QueryResult
if err := c.doJSON(ctx, "GET", queryURL, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
// Skeptic queries the "Trust but Verify" endpoint.
//
// Unlike Query which picks a winner via a lens, Skeptic shows all
// competing claims with their relative weights and conflict analysis.
//
// Example:
//
// result, err := client.Skeptic(ctx, steme.SkepticQueryParams{
// Subject: "Semaglutide",
// Predicate: "muscle_effect",
// })
//
// fmt.Printf("Status: %s, Conflict: %.2f\n", result.Status, result.ConflictScore)
// for _, claim := range result.Claims {
// fmt.Printf(" %v (%.1f%% support)\n", claim.Value, claim.WeightShare*100)
// }
func (c *Client) Skeptic(ctx context.Context, params SkepticQueryParams) (*SkepticResult, error) {
queryURL := "/v1/skeptic"
values := url.Values{}
values.Add("subject", params.Subject)
values.Add("predicate", params.Predicate)
queryURL += "?" + values.Encode()
var result SkepticResult
if err := c.doJSON(ctx, "GET", queryURL, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
// Trace queries the audit log for agent decision tracing.
//
// Returns query audits for a specific agent within a time range, optionally
// filtered by subject pattern. This enables "Why did the agent think that?"
// debugging for incident investigation.
//
// Example:
//
// result, err := client.Trace(ctx, steme.TraceParams{
// AgentID: "deadbeef...",
// From: "1704067200",
// To: "1704153600",
// Subject: "Tesla*",
// Limit: 50,
// })
//
// for _, audit := range result.Audits {
// fmt.Printf("Query %s at %d\n", audit.QueryID, audit.Timestamp)
// }
func (c *Client) Trace(ctx context.Context, params TraceParams) (*TraceResult, error) {
queryURL := "/v1/trace"
// Build query parameters
values := url.Values{}
values.Add("agent_id", params.AgentID)
values.Add("from", params.From)
if params.To != "" {
values.Add("to", params.To)
}
if params.Subject != "" {
values.Add("subject", params.Subject)
}
if params.Limit > 0 {
values.Add("limit", fmt.Sprintf("%d", params.Limit))
}
queryURL += "?" + values.Encode()
var result TraceResult
if err := c.doJSON(ctx, "GET", queryURL, nil, &result); err != nil {
return nil, err
}
return &result, nil
}
// Supersede creates a supersession record for error correction.
//
// Supersession enables error correction without violating append-only semantics.
// Instead of mutating an assertion, we create a supersession record that points
// from the old (target) to the new (replacement) assertion.
//
// Example:
//
// result, err := client.Supersede(ctx, steme.SupersedeParams{
// TargetHash: "abc123...",
// SupersessionType: steme.SupersessionInvalidate,
// Reason: "Proposal treated as approved. See incident INC-2024-001",
// NewHash: "def456...",
// AgentID: "deadbeef...",
// Signature: "...",
// })
func (c *Client) Supersede(ctx context.Context, params SupersedeParams) (*SupersedeResult, error) {
var result SupersedeResult
if err := c.doJSON(ctx, "POST", "/v1/supersede", params, &result); err != nil {
return nil, err
}
return &result, nil
}
// Health checks the API health status.
//
// Returns the service status, version, and assertion count.
func (c *Client) Health(ctx context.Context) (*HealthResponse, error) {
var resp HealthResponse
if err := c.doJSON(ctx, "GET", "/v1/health", nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// HealthResponse represents the health check response.
type HealthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
AssertionsCount uint64 `json:"assertions_count"`
}
// signAssertion creates a signature for an assertion.
//
// The signature is over the canonical representation of the assertion:
// BLAKE2b(subject || predicate || object_bytes || confidence || source_hash)
func (c *Client) signAssertion(a *Assertion) (SignatureEntry, error) {
// Build canonical message for signing
message, err := canonicalAssertionMessage(a)
if err != nil {
return SignatureEntry{}, err
}
// Create signature entry
return c.signer.CreateSignature(message), nil
}
// canonicalAssertionMessage creates the canonical byte representation for signing.
//
// This must match the server's signature verification logic.
// Uses SHA256 for the canonical hash.
func canonicalAssertionMessage(a *Assertion) ([]byte, error) {
h := sha256.New()
// subject
h.Write([]byte(a.Subject))
h.Write([]byte{0}) // separator
// predicate
h.Write([]byte(a.Predicate))
h.Write([]byte{0})
// object (serialize as JSON for determinism)
objBytes, err := json.Marshal(a.Object)
if err != nil {
return nil, fmt.Errorf("steme: failed to serialize object: %w", err)
}
h.Write(objBytes)
h.Write([]byte{0})
// confidence (as bytes)
confidenceBytes, err := json.Marshal(a.Confidence)
if err != nil {
return nil, fmt.Errorf("steme: failed to serialize confidence: %w", err)
}
h.Write(confidenceBytes)
h.Write([]byte{0})
// source_hash
sourceHashBytes, err := hex.DecodeString(a.SourceHash)
if err != nil {
return nil, fmt.Errorf("steme: invalid source_hash hex: %w", err)
}
h.Write(sourceHashBytes)
return h.Sum(nil), nil
}
// doJSON performs an HTTP request with JSON encoding/decoding.
func (c *Client) doJSON(ctx context.Context, method, path string, body any, result any) error {
fullURL := c.baseURL + path
var reqBody io.Reader
if body != nil {
jsonBytes, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("steme: failed to marshal request: %w", err)
}
reqBody = bytes.NewReader(jsonBytes)
}
req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody)
if err != nil {
return fmt.Errorf("steme: failed to create request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
// Add agent_id header for audit trail
req.Header.Set("X-Agent-Id", c.signer.PublicKey())
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("steme: request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("steme: failed to read response: %w", err)
}
// Check for HTTP errors
if resp.StatusCode >= 400 {
var apiErr struct {
Error string `json:"error"`
Code string `json:"code"`
}
// Try to parse error response
if err := json.Unmarshal(respBody, &apiErr); err == nil && apiErr.Error != "" {
return &APIError{
StatusCode: resp.StatusCode,
Code: apiErr.Code,
Message: apiErr.Error,
}
}
// Fallback to generic error
return &APIError{
StatusCode: resp.StatusCode,
Code: "UNKNOWN",
Message: string(respBody),
}
}
// Decode success response
if result != nil {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("steme: failed to decode response: %w", err)
}
}
return nil
}