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>
397 lines
10 KiB
Go
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
|
|
}
|