think
16px
820px

AHU AI Observatory — Backend (P1) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the ahu-ai-observatory backend: a Go ingester that consumes gateway audit events from Redis Stream ahu.ai.audit into a tiered TimescaleDB store (queryable metadata + hash-chained append-only bodies), plus a role-gated query API that will feed the dashboard (plan 2) and, later, the Predictive Intelligence Engine.

Architecture: One Go binary (cmd/observatory) running two loops: an ingester (Redis consumer group → Postgres transactions, at-least-once with event_id dedup) and an HTTP query API (:8300). TimescaleDB holds a hypertable for call metadata with a continuous aggregate for dashboard rollups; bodies live in an append-only, trigger-protected, hash-chained table. Every body view is itself recorded (Access Audit Trail).

Tech Stack: Go ≥1.25 (std net/http) · github.com/jackc/pgx/v5 (+pgxpool) · github.com/redis/go-redis/v9 · gopkg.in/yaml.v3 · github.com/klauspost/compress/zstd · github.com/prometheus/client_golang · test-only github.com/alicebob/miniredis/v2. Database: timescale/timescaledb:latest-pg16.

Global Constraints

  • Repo: /home/efran/remote-development/poc-ahu-ai/ahu-ai-observatory (git repo, branch feat/observatory-p1 — create it in Task 1). Module path datahive.id/ahu-ai-observatory.
  • Contract values from ../ahu-gpu-manager/docs/CONVENTIONS.md v1.0 (on conflict it wins): stream key ahu.ai.audit; observatory API port 8300; audit event schema v1 field names exactly as produced by the gateway (see ../ahu-gpu-manager/internal/audit/event.go — the JSON tags there are the wire truth; consume them, do not redefine semantics).
  • Delivery contract with the gateway: at-least-once — the ingester MUST dedup on event_id. Bodies arrive as zstd-compressed base64 inside the event JSON (request_body_zst/response_body_zst); store compressed, decompress only at read time.
  • Immutability: bodies table blocks UPDATE/DELETE/TRUNCATE via triggers (same pattern as the OCR repo's audit tables). Hash chain: row_hash = sha256(prev_hash ‖ event_id ‖ sha256(request_body_zst) ‖ sha256(response_body_zst)), prev_hash of the first row = 32 zero bytes.
  • Roles: executive (summary/series only), operator (+ calls metadata), auditor (+ bodies, chain verify). Static bearer tokens in config. Every body read inserts a body_access_audit row in the same transaction as nothing — it must be written even if the caller then aborts (plain insert, autocommit).
  • Test DB: tasks use a throwaway TimescaleDB at postgres://postgres:test@localhost:5434/postgres — Task 2 creates scripts/testdb.sh to run it; tests skip with a clear message if TEST_PG_DSN is unset AND localhost:5434 is unreachable.
  • No new dependencies beyond the Tech Stack list. TDD; commit per task; commit messages end with Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT.
  • Every created/modified .md file gets uploaded: curl -F "file=@<file>" https://x056.think.val.id/upload.
  • Engine repos and ahu-gpu-manager are read-only context — never modified by this plan.
  • All tests: go test ./... -count=1; -race on ingester/store tasks.

File Structure

cmd/observatory/main.go        wiring: config  pool  migrator  ingester + API server
internal/config/config.go      YAML config: pg dsn, redis, stream/group, listen, role tokens, retention
internal/store/migrate.go      embedded SQL migrations, applied on boot (idempotent)
internal/store/migrations/*.sql
internal/store/calls.go        metadata inserts (dedup) + query helpers (summary/series/calls)
internal/store/bodies.go       hash-chained body inserts (advisory-lock serialized), reads, chain verify
internal/store/access.go       body_access_audit writes + reads
internal/ingest/ingester.go    consumer-group loop, parse, tx insert, ack, autoclaim, metrics
internal/api/server.go         mux, auth middleware (token→role), handlers, healthz, metrics
deploy/observatory.example.yaml
deploy/compose.yaml            observatory + timescaledb; joins external ahu-platform_default network
Dockerfile
scripts/testdb.sh              throwaway timescaledb for tests (port 5434)

Task 1: Scaffold + config loader

Files:
- Create: go.mod, internal/config/config.go
- Test: internal/config/config_test.go

Interfaces:
- Produces: config.Load(path string) (*Config, error); types:

type Role string // "executive" | "operator" | "auditor"
type Config struct {
    Listen        string            `yaml:"listen"`         // default ":8300"
    PGDSN         string            `yaml:"pg_dsn"`
    RedisURL      string            `yaml:"redis_url"`
    StreamKey     string            `yaml:"stream_key"`     // default "ahu.ai.audit"
    ConsumerGroup string            `yaml:"consumer_group"` // default "observatory"
    ConsumerName  string            `yaml:"consumer_name"`  // default hostname; fallback "obs-1"
    Tokens        map[string]Role   `yaml:"tokens"`         // bearer token -> role
    RetentionDays int               `yaml:"retention_days"` // metadata retention, default 400 (~13 months)
}
  • [ ] Step 1: Init repo state
cd /home/efran/remote-development/poc-ahu-ai/ahu-ai-observatory
git checkout -b feat/observatory-p1 2>/dev/null || git checkout feat/observatory-p1
go mod init datahive.id/ahu-ai-observatory
  • [ ] Step 2: Write the failing test

internal/config/config_test.go:

package config

import (
    "os"
    "path/filepath"
    "testing"
)

func write(t *testing.T, s string) string {
    t.Helper()
    p := filepath.Join(t.TempDir(), "o.yaml")
    if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
        t.Fatal(err)
    }
    return p
}

func TestLoadValidAndDefaults(t *testing.T) {
    c, err := Load(write(t, `
pg_dsn: "postgres://u:p@h:5432/db"
redis_url: "redis://r:6379/0"
tokens:
  tok-exec: executive
  tok-aud: auditor
`))
    if err != nil {
        t.Fatal(err)
    }
    if c.Listen != ":8300" || c.StreamKey != "ahu.ai.audit" || c.ConsumerGroup != "observatory" {
        t.Fatalf("defaults wrong: %+v", c)
    }
    if c.RetentionDays != 400 || c.ConsumerName == "" {
        t.Fatalf("defaults wrong: %+v", c)
    }
    if c.Tokens["tok-aud"] != "auditor" {
        t.Fatalf("tokens: %+v", c.Tokens)
    }
}

func TestLoadRejectsBadRoleAndMissingDSN(t *testing.T) {
    if _, err := Load(write(t, "pg_dsn: x\nredis_url: y\ntokens: {t: superuser}")); err == nil {
        t.Fatal("bad role accepted")
    }
    if _, err := Load(write(t, "redis_url: y")); err == nil {
        t.Fatal("missing pg_dsn accepted")
    }
}
  • [ ] Step 3: Run to verify failurego test ./internal/config/ -v → FAIL (Load undefined).

  • [ ] Step 4: Implement

internal/config/config.go:

package config

import (
    "fmt"
    "os"

    "gopkg.in/yaml.v3"
)

type Role string

const (
    RoleExecutive Role = "executive"
    RoleOperator  Role = "operator"
    RoleAuditor   Role = "auditor"
)

type Config struct {
    Listen        string          `yaml:"listen"`
    PGDSN         string          `yaml:"pg_dsn"`
    RedisURL      string          `yaml:"redis_url"`
    StreamKey     string          `yaml:"stream_key"`
    ConsumerGroup string          `yaml:"consumer_group"`
    ConsumerName  string          `yaml:"consumer_name"`
    Tokens        map[string]Role `yaml:"tokens"`
    RetentionDays int             `yaml:"retention_days"`
}

func Load(path string) (*Config, error) {
    b, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    var c Config
    if err := yaml.Unmarshal(b, &c); err != nil {
        return nil, err
    }
    if c.PGDSN == "" || c.RedisURL == "" {
        return nil, fmt.Errorf("pg_dsn and redis_url are required")
    }
    if c.Listen == "" {
        c.Listen = ":8300"
    }
    if c.StreamKey == "" {
        c.StreamKey = "ahu.ai.audit"
    }
    if c.ConsumerGroup == "" {
        c.ConsumerGroup = "observatory"
    }
    if c.ConsumerName == "" {
        if h, err := os.Hostname(); err == nil && h != "" {
            c.ConsumerName = h
        } else {
            c.ConsumerName = "obs-1"
        }
    }
    if c.RetentionDays == 0 {
        c.RetentionDays = 400
    }
    for tok, r := range c.Tokens {
        switch r {
        case RoleExecutive, RoleOperator, RoleAuditor:
        default:
            return nil, fmt.Errorf("token %q: invalid role %q", tok, r)
        }
    }
    return &c, nil
}
  • [ ] Step 5: Pass + commit
go get gopkg.in/yaml.v3 && go test ./internal/config/ -v
git add go.mod go.sum internal/config/ .gitignore docs/
git commit -m "feat(config): observatory config loader with role validation

Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"

Task 2: Schema migrations + migrator + test DB harness

Files:
- Create: scripts/testdb.sh, internal/store/migrate.go, internal/store/migrations/001_schema.sql
- Test: internal/store/migrate_test.go

Interfaces:
- Produces: store.Migrate(ctx context.Context, pool *pgxpool.Pool) error (idempotent); test helper store.TestPool(t *testing.T) *pgxpool.Pool (connects to test DB, skips if unavailable, runs Migrate). TestPool does NOT truncate anything (the bodies table is append-only by design): every test must be namespace-safe — unique ULID event_ids, per-test unique engine/tenant names, and assertions filtered to the test's own rows, never bare table counts. (Amended after Task 2: original text promised truncation; namespace-safety is the actual contract.)

  • [ ] Step 1: Test DB script

scripts/testdb.sh:

#!/usr/bin/env bash
# Throwaway TimescaleDB for observatory tests (port 5434 — 5433 is taken by the OCR stack Postgres). Usage: ./scripts/testdb.sh [stop]
set -euo pipefail
if [ "${1:-}" = "stop" ]; then docker rm -f obs-test-pg >/dev/null 2>&1 || true; exit 0; fi
docker rm -f obs-test-pg >/dev/null 2>&1 || true
docker run -d --name obs-test-pg -p 5434:5432 -e POSTGRES_PASSWORD=test timescale/timescaledb:latest-pg16 >/dev/null
for i in $(seq 1 30); do
  docker exec obs-test-pg pg_isready -U postgres >/dev/null 2>&1 && { echo "ready: postgres://postgres:test@localhost:5434/postgres"; exit 0; }
  sleep 1
done
echo "testdb failed to start" >&2; exit 1

chmod +x scripts/testdb.sh && ./scripts/testdb.sh

  • [ ] Step 2: Write the failing test

internal/store/migrate_test.go:

package store

import (
    "context"
    "testing"
)

func TestMigrateIdempotentAndSchemaShape(t *testing.T) {
    pool := TestPool(t)
    ctx := context.Background()
    if err := Migrate(ctx, pool); err != nil { // second run (TestPool already ran it once)
        t.Fatalf("re-migrate not idempotent: %v", err)
    }
    var isHyper bool
    err := pool.QueryRow(ctx,
        `SELECT count(*) = 1 FROM timescaledb_information.hypertables WHERE hypertable_name = 'ai_calls'`).Scan(&isHyper)
    if err != nil || !isHyper {
        t.Fatalf("ai_calls not a hypertable: %v %v", isHyper, err)
    }
    var n int
    if err := pool.QueryRow(ctx,
        `SELECT count(*) FROM timescaledb_information.continuous_aggregates WHERE view_name = 'ai_calls_1m'`).Scan(&n); err != nil || n != 1 {
        t.Fatalf("continuous aggregate missing: %d %v", n, err)
    }
    for _, tbl := range []string{"ai_call_bodies", "body_access_audit"} {
        if err := pool.QueryRow(ctx, `SELECT count(*) FROM `+tbl).Scan(&n); err != nil {
            t.Fatalf("table %s missing: %v", tbl, err)
        }
    }
}
  • [ ] Step 3: Run to verify failurego test ./internal/store/ -v → FAIL (undefined: TestPool/Migrate).

  • [ ] Step 4: Implement migrations

internal/store/migrations/001_schema.sql:

CREATE EXTENSION IF NOT EXISTS timescaledb;

CREATE TABLE IF NOT EXISTS ai_calls (
  event_id       text        NOT NULL,
  ts             timestamptz NOT NULL,
  engine         text NOT NULL DEFAULT '',
  surface        text NOT NULL DEFAULT '',
  user_id        text NOT NULL DEFAULT '',
  trace_id       text NOT NULL DEFAULT '',
  upstream       text NOT NULL DEFAULT '',
  model          text NOT NULL DEFAULT '',
  upstream_class text NOT NULL DEFAULT '',
  traffic_class  text NOT NULL DEFAULT '',
  operation      text NOT NULL DEFAULT '',
  status         text NOT NULL DEFAULT '',
  error_code     text NOT NULL DEFAULT '',
  queue_ms       bigint NOT NULL DEFAULT 0,
  upstream_ms    bigint NOT NULL DEFAULT 0,
  total_ms       bigint NOT NULL DEFAULT 0,
  tokens_in      int,
  tokens_out     int,
  doc_hash       text NOT NULL DEFAULT '',
  pages          int,
  job_id         text NOT NULL DEFAULT '',
  parent_job_id  text NOT NULL DEFAULT '',
  config_version text NOT NULL DEFAULT '',
  body_ref       bigint,
  body_truncated boolean NOT NULL DEFAULT false,
  ingested_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (event_id, ts)
);
SELECT create_hypertable('ai_calls', 'ts', chunk_time_interval => INTERVAL '1 day', if_not_exists => TRUE);
CREATE INDEX IF NOT EXISTS ai_calls_engine_ts ON ai_calls (engine, ts DESC);
CREATE INDEX IF NOT EXISTS ai_calls_trace ON ai_calls (trace_id);

CREATE MATERIALIZED VIEW IF NOT EXISTS ai_calls_1m
WITH (timescaledb.continuous) AS
SELECT time_bucket('1 minute', ts) AS bucket,
       engine, upstream, traffic_class, status,
       count(*)                AS calls,
       sum(coalesce(tokens_in,0))  AS tokens_in,
       sum(coalesce(tokens_out,0)) AS tokens_out,
       avg(total_ms)           AS avg_total_ms,
       max(total_ms)           AS max_total_ms,
       avg(queue_ms)           AS avg_queue_ms
FROM ai_calls GROUP BY bucket, engine, upstream, traffic_class, status
WITH NO DATA;
SELECT add_continuous_aggregate_policy('ai_calls_1m',
  start_offset => INTERVAL '2 hours', end_offset => INTERVAL '1 minute',
  schedule_interval => INTERVAL '1 minute', if_not_exists => TRUE);

CREATE TABLE IF NOT EXISTS ai_call_bodies (
  chain_pos         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  event_id          text NOT NULL UNIQUE,
  ts                timestamptz NOT NULL,
  request_body_zst  bytea,
  response_body_zst bytea,
  full_body_sha256  text NOT NULL DEFAULT '',
  prev_hash         bytea NOT NULL,
  row_hash          bytea NOT NULL
);

CREATE OR REPLACE FUNCTION ai_call_bodies_immutable() RETURNS trigger AS $$
BEGIN RAISE EXCEPTION 'ai_call_bodies is append-only'; END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS bodies_no_update ON ai_call_bodies;
CREATE TRIGGER bodies_no_update BEFORE UPDATE OR DELETE ON ai_call_bodies
  FOR EACH ROW EXECUTE FUNCTION ai_call_bodies_immutable();
DROP TRIGGER IF EXISTS bodies_no_truncate ON ai_call_bodies;
CREATE TRIGGER bodies_no_truncate BEFORE TRUNCATE ON ai_call_bodies
  FOR EACH STATEMENT EXECUTE FUNCTION ai_call_bodies_immutable();

CREATE TABLE IF NOT EXISTS body_access_audit (
  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  at       timestamptz NOT NULL DEFAULT now(),
  actor    text NOT NULL,
  role     text NOT NULL,
  event_id text NOT NULL,
  action   text NOT NULL
);

internal/store/migrate.go:

package store

import (
    "context"
    "embed"
    "fmt"
    "sort"

    "github.com/jackc/pgx/v5/pgxpool"
)

//go:embed migrations/*.sql
var migrationsFS embed.FS

func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
    entries, err := migrationsFS.ReadDir("migrations")
    if err != nil {
        return err
    }
    names := make([]string, 0, len(entries))
    for _, e := range entries {
        names = append(names, e.Name())
    }
    sort.Strings(names)
    for _, n := range names {
        sql, err := migrationsFS.ReadFile("migrations/" + n)
        if err != nil {
            return err
        }
        if _, err := pool.Exec(ctx, string(sql)); err != nil {
            return fmt.Errorf("migration %s: %w", n, err)
        }
    }
    return nil
}

Note: CREATE MATERIALIZED VIEW IF NOT EXISTS ... WITH (timescaledb.continuous) cannot run inside an implicit transaction block with other statements on some Timescale versions. If Migrate fails on that statement, split 001 into 001_schema.sql (tables/triggers) and 002_aggregate.sql (the view + policy) and execute each file separately (the loop already does) — and if the view still fails inside pool.Exec's implicit transaction, execute that file via a raw connection with pgx.Conn.Exec using simple protocol (pool.Acquireconn.Conn().Exec). Document which was needed in the report.

Add the test helper in internal/store/testutil.go:

package store

import (
    "context"
    "os"
    "testing"

    "github.com/jackc/pgx/v5/pgxpool"
)

func TestPool(t *testing.T) *pgxpool.Pool {
    t.Helper()
    dsn := os.Getenv("TEST_PG_DSN")
    if dsn == "" {
        dsn = "postgres://postgres:test@localhost:5434/postgres"
    }
    pool, err := pgxpool.New(context.Background(), dsn)
    if err == nil {
        err = pool.Ping(context.Background())
    }
    if err != nil {
        t.Skipf("test DB unavailable (run ./scripts/testdb.sh): %v", err)
    }
    if err := Migrate(context.Background(), pool); err != nil {
        t.Fatalf("migrate: %v", err)
    }
    t.Cleanup(pool.Close)
    return pool
}
  • [ ] Step 5: Pass + commit
go get github.com/jackc/pgx/v5 && go test ./internal/store/ -v
git add internal/store/ scripts/
git commit -m "feat(store): timescale schema, idempotent migrator, test harness

Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"

Task 3: Body store — hash-chained inserts, immutability, verification

Files:
- Create: internal/store/bodies.go
- Test: internal/store/bodies_test.go

Interfaces:
- Produces:

type Body struct {
    EventID        string
    TS             time.Time
    RequestZst     []byte
    ResponseZst    []byte
    FullBodySHA256 string
}
// InsertBody appends within tx, serialized by pg advisory xact lock 4711.
// Returns chain_pos. Duplicate event_id returns (0, ErrDuplicate).
func InsertBody(ctx context.Context, tx pgx.Tx, b Body) (int64, error)
func GetBody(ctx context.Context, pool *pgxpool.Pool, eventID string) (*StoredBody, error) // ErrNotFound
type StoredBody struct{ ChainPos int64; Body; PrevHash, RowHash []byte }
// VerifyChain recomputes hashes over [fromPos,toPos]; returns first bad pos or 0.
func VerifyChain(ctx context.Context, pool *pgxpool.Pool, fromPos, toPos int64) (int64, error)
var ErrDuplicate = errors.New("duplicate body")
var ErrNotFound = errors.New("not found")

Hash rule (Global Constraints): row_hash = sha256(prev_hash ‖ event_id ‖ sha256(req_zst) ‖ sha256(resp_zst)); nil body slices hash as sha256 of empty input; genesis prev_hash = 32 zero bytes.

  • [ ] Step 1: Write the failing tests

internal/store/bodies_test.go:

package store

import (
    "context"
    "crypto/sha256"
    "testing"
    "time"

    "github.com/oklog/ulid/v2"
)

func insertOne(t *testing.T, pool *pgxpool.Pool, req, resp []byte) (string, int64) {
    t.Helper()
    ctx := context.Background()
    id := ulid.Make().String()
    tx, err := pool.Begin(ctx)
    if err != nil {
        t.Fatal(err)
    }
    pos, err := InsertBody(ctx, tx, Body{EventID: id, TS: time.Now(), RequestZst: req, ResponseZst: resp})
    if err != nil {
        t.Fatal(err)
    }
    if err := tx.Commit(ctx); err != nil {
        t.Fatal(err)
    }
    return id, pos
}

func TestChainLinksAndVerifies(t *testing.T) {
    pool := TestPool(t)
    ctx := context.Background()
    _, p1 := insertOne(t, pool, []byte("req1"), []byte("resp1"))
    id2, p2 := insertOne(t, pool, []byte("req2"), nil)
    if p2 <= p1 {
        t.Fatalf("chain positions not increasing: %d %d", p1, p2)
    }
    b2, err := GetBody(ctx, pool, id2)
    if err != nil {
        t.Fatal(err)
    }
    // b2.prev_hash must equal row_hash of the row before it in the chain
    var prevRow []byte
    if err := pool.QueryRow(ctx,
        `SELECT row_hash FROM ai_call_bodies WHERE chain_pos < $1 ORDER BY chain_pos DESC LIMIT 1`,
        b2.ChainPos).Scan(&prevRow); err != nil {
        t.Fatal(err)
    }
    if string(b2.PrevHash) != string(prevRow) {
        t.Fatal("prev_hash does not link to predecessor row_hash")
    }
    reqH := sha256.Sum256([]byte("req2"))
    respH := sha256.Sum256(nil)
    want := sha256.Sum256(append(append(append(append([]byte{}, b2.PrevHash...), []byte(id2)...), reqH[:]...), respH[:]...))
    if string(b2.RowHash) != string(want[:]) {
        t.Fatal("row_hash formula mismatch")
    }
    if bad, err := VerifyChain(ctx, pool, 0, 0); err != nil || bad != 0 {
        t.Fatalf("verify: bad=%d err=%v", bad, err)
    }
}

func TestDuplicateAndImmutability(t *testing.T) {
    pool := TestPool(t)
    ctx := context.Background()
    id, pos := insertOne(t, pool, []byte("x"), []byte("y"))
    tx, _ := pool.Begin(ctx)
    if _, err := InsertBody(ctx, tx, Body{EventID: id, TS: time.Now()}); err != ErrDuplicate {
        t.Fatalf("want ErrDuplicate, got %v", err)
    }
    tx.Rollback(ctx)
    if _, err := pool.Exec(ctx, `UPDATE ai_call_bodies SET full_body_sha256='hax' WHERE chain_pos=$1`, pos); err == nil {
        t.Fatal("UPDATE not blocked")
    }
    if _, err := pool.Exec(ctx, `DELETE FROM ai_call_bodies WHERE chain_pos=$1`, pos); err == nil {
        t.Fatal("DELETE not blocked")
    }
}
  • [ ] Step 2: Run to verify failurego test ./internal/store/ -run 'TestChain|TestDuplicate' -v → FAIL.

  • [ ] Step 3: Implement

internal/store/bodies.go:

package store

import (
    "context"
    "crypto/sha256"
    "errors"
    "time"

    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

var (
    ErrDuplicate = errors.New("duplicate body")
    ErrNotFound  = errors.New("not found")
)

type Body struct {
    EventID        string
    TS             time.Time
    RequestZst     []byte
    ResponseZst    []byte
    FullBodySHA256 string
}

type StoredBody struct {
    ChainPos int64
    Body
    PrevHash []byte
    RowHash  []byte
}

func rowHash(prev []byte, eventID string, req, resp []byte) []byte {
    rq := sha256.Sum256(req)
    rs := sha256.Sum256(resp)
    h := sha256.New()
    h.Write(prev)
    h.Write([]byte(eventID))
    h.Write(rq[:])
    h.Write(rs[:])
    return h.Sum(nil)
}

func InsertBody(ctx context.Context, tx pgx.Tx, b Body) (int64, error) {
    // Serialize chain appends: xact-scoped advisory lock releases on commit/rollback.
    if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(4711)`); err != nil {
        return 0, err
    }
    var dup bool
    if err := tx.QueryRow(ctx,
        `SELECT EXISTS(SELECT 1 FROM ai_call_bodies WHERE event_id=$1)`, b.EventID).Scan(&dup); err != nil {
        return 0, err
    }
    if dup {
        return 0, ErrDuplicate
    }
    prev := make([]byte, 32)
    err := tx.QueryRow(ctx,
        `SELECT row_hash FROM ai_call_bodies ORDER BY chain_pos DESC LIMIT 1`).Scan(&prev)
    if err != nil && err != pgx.ErrNoRows {
        return 0, err
    }
    rh := rowHash(prev, b.EventID, b.RequestZst, b.ResponseZst)
    var pos int64
    err = tx.QueryRow(ctx, `
        INSERT INTO ai_call_bodies (event_id, ts, request_body_zst, response_body_zst, full_body_sha256, prev_hash, row_hash)
        VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING chain_pos`,
        b.EventID, b.TS, b.RequestZst, b.ResponseZst, b.FullBodySHA256, prev, rh).Scan(&pos)
    return pos, err
}

func GetBody(ctx context.Context, pool *pgxpool.Pool, eventID string) (*StoredBody, error) {
    var s StoredBody
    err := pool.QueryRow(ctx, `
        SELECT chain_pos, event_id, ts, request_body_zst, response_body_zst, full_body_sha256, prev_hash, row_hash
        FROM ai_call_bodies WHERE event_id=$1`, eventID).
        Scan(&s.ChainPos, &s.EventID, &s.TS, &s.RequestZst, &s.ResponseZst, &s.FullBodySHA256, &s.PrevHash, &s.RowHash)
    if err == pgx.ErrNoRows {
        return nil, ErrNotFound
    }
    return &s, err
}

// VerifyChain returns the first chain_pos whose row_hash does not verify, or 0 if all good.
// fromPos/toPos of 0 mean unbounded.
func VerifyChain(ctx context.Context, pool *pgxpool.Pool, fromPos, toPos int64) (int64, error) {
    q := `SELECT chain_pos, event_id, request_body_zst, response_body_zst, prev_hash, row_hash
          FROM ai_call_bodies WHERE ($1 = 0 OR chain_pos >= $1) AND ($2 = 0 OR chain_pos <= $2)
          ORDER BY chain_pos`
    rows, err := pool.Query(ctx, q, fromPos, toPos)
    if err != nil {
        return 0, err
    }
    defer rows.Close()
    var prev []byte
    first := true
    for rows.Next() {
        var pos int64
        var id string
        var req, resp, ph, rh []byte
        if err := rows.Scan(&pos, &id, &req, &resp, &ph, &rh); err != nil {
            return 0, err
        }
        if first && fromPos > 1 {
            prev = ph // mid-chain start: trust the stored prev_hash as anchor
        }
        if first && fromPos <= 1 {
            prev = make([]byte, 32)
        }
        first = false
        if string(ph) != string(prev) {
            return pos, nil
        }
        if string(rowHash(prev, id, req, resp)) != string(rh) {
            return pos, nil
        }
        prev = rh
    }
    return 0, rows.Err()
}
  • [ ] Step 4: Pass + commit
go get github.com/oklog/ulid/v2 && go test ./internal/store/ -v -race
git add internal/store/ go.mod go.sum
git commit -m "feat(store): hash-chained append-only body store with verification

Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"

Task 4: Metadata store — dedup insert + query helpers

Files:
- Create: internal/store/calls.go, internal/store/access.go
- Test: internal/store/calls_test.go

Interfaces:
- Produces:

// Event mirrors the gateway's audit schema v1 JSON exactly (see
// ../ahu-gpu-manager/internal/audit/event.go). []byte fields carry zstd bytes
// (encoding/json base64-decodes them automatically on unmarshal).
type Event struct {
    EventID string `json:"event_id"`
    SchemaVer int  `json:"schema_ver"`
    TS      string `json:"ts"`
    Engine  string `json:"engine"`
    Surface string `json:"surface"`
    UserID  string `json:"user_id"`
    TraceID string `json:"trace_id"`
    Upstream string `json:"upstream"`
    Model   string `json:"model"`
    UpstreamClass string `json:"upstream_class"`
    TrafficClass  string `json:"traffic_class"`
    Operation string `json:"operation"`
    Status    string `json:"status"`
    ErrorCode string `json:"error_code"`
    QueueMs   int64  `json:"queue_ms"`
    UpstreamMs int64 `json:"upstream_ms"`
    TotalMs   int64  `json:"total_ms"`
    TokensIn  *int   `json:"tokens_in"`
    TokensOut *int   `json:"tokens_out"`
    DocHash   string `json:"doc_hash"`
    Pages     *int   `json:"pages"`
    JobID     string `json:"job_id"`
    ParentJobID string `json:"parent_job_id"`
    ConfigVersion string `json:"config_version"`
    RequestBodyZst  []byte `json:"request_body_zst"`
    ResponseBodyZst []byte `json:"response_body_zst"`
    BodyTruncated   bool   `json:"body_truncated"`
    FullBodySHA256  string `json:"full_body_sha256"`
}
// InsertEvent: one tx — body row (when bodies present) then metadata with
// body_ref; dedup on event_id (bodies dup OR metadata conflict → inserted=false, nil error).
func InsertEvent(ctx context.Context, pool *pgxpool.Pool, ev Event) (inserted bool, err error)
type CallFilter struct{ From, To time.Time; Engine, Upstream, Status, TraceID string; Limit int; BeforeTS time.Time; BeforeID string }
func QueryCalls(ctx, pool, f CallFilter) ([]CallRow, error)     // metadata list, ts DESC, keyset pagination
func QuerySummary(ctx, pool, from, to time.Time) ([]SummaryRow, error)   // totals by engine+status (raw table)
func QuerySeries(ctx, pool, from, to time.Time, bucket time.Duration, groupBy string) ([]SeriesRow, error) // groupBy ∈ engine|upstream|traffic_class (validated)
func LogBodyAccess(ctx, pool, actor, role, eventID, action string) error
func QueryBodyAccess(ctx, pool, limit int) ([]AccessRow, error)

Row types contain the obvious fields; QuerySeries MUST validate groupBy against an allowlist and interpolate the column name only from that allowlist (never from user input). Timestamps: ev.TS parses RFC3339Nano; parse failure → use time.Now().UTC() and count it (return via named metric hook later; for now a package-level counter var).

  • [ ] Step 1: Failing testsinternal/store/calls_test.go with three tests: TestInsertEventDedups (same event twice → first inserted=true with body_ref set and body row present; second inserted=false, no new rows), TestQueryCallsFilterAndPagination (insert 5 events across 2 engines, filter by engine, limit 2 + keyset-paginate to exhaustion), TestSeriesGroupByValidation (groupBy:"tenant; DROP TABLE" → error; groupBy:"engine" → rows). Write realistic events (ts=RFC3339Nano strings, bodies on some). Use the exact helper style of Task 3's tests.
  • [ ] Step 2: Verify failure, then implement calls.go + access.go. InsertEvent skeleton:
func InsertEvent(ctx context.Context, pool *pgxpool.Pool, ev Event) (bool, error) {
    ts, err := time.Parse(time.RFC3339Nano, ev.TS)
    if err != nil {
        ts = time.Now().UTC()
        badTSCount.Add(1)
    }
    tx, err := pool.Begin(ctx)
    if err != nil {
        return false, err
    }
    defer tx.Rollback(ctx)
    var bodyRef *int64
    if len(ev.RequestBodyZst) > 0 || len(ev.ResponseBodyZst) > 0 {
        pos, err := InsertBody(ctx, tx, Body{EventID: ev.EventID, TS: ts,
            RequestZst: ev.RequestBodyZst, ResponseZst: ev.ResponseBodyZst, FullBodySHA256: ev.FullBodySHA256})
        switch err {
        case nil:
            bodyRef = &pos
        case ErrDuplicate:
            return false, tx.Commit(ctx) // whole event is a redelivery
        default:
            return false, err
        }
    }
    ct, err := tx.Exec(ctx, `INSERT INTO ai_calls (...27 cols...) VALUES (...) ON CONFLICT (event_id, ts) DO NOTHING`, /* all fields, bodyRef */)
    if err != nil {
        return false, err
    }
    if err := tx.Commit(ctx); err != nil {
        return false, err
    }
    return ct.RowsAffected() == 1, nil
}

(Write the full column list explicitly — no shortcuts; keep column order matching 001_schema.sql.)

  • [ ] Step 3: Pass + commitgo test ./internal/store/ -v -race; commit feat(store): event metadata inserts with dedup + dashboard query helpers.

Task 5: Ingester — consumer group loop with autoclaim + metrics

Files:
- Create: internal/ingest/ingester.go
- Test: internal/ingest/ingester_test.go

Interfaces:
- Produces:

func New(rdb redis.UniversalClient, pool *pgxpool.Pool, streamKey, group, consumer string, reg prometheus.Registerer) *Ingester
func (i *Ingester) Run(ctx context.Context) error // returns when ctx done
// Metrics on reg: obs_ingested_total, obs_deduped_total, obs_parse_errors_total,
// obs_pending gauge (XPENDING count, refreshed each loop)

Semantics: ensure group exists (XGROUP CREATE <stream> <group> 0 MKSTREAM, ignore BUSYGROUP — starting at 0 ingests all history, deliberate); loop: XAUTOCLAIM (min-idle 60s, count 50) then XREADGROUP COUNT 100 BLOCK 3000 on >; for each message parse field json into store.Event (parse error → count + XACK + continue — poison messages must not wedge the group); store.InsertEvent; on success or dedup → XACK; on DB error → do NOT ack (retry via pending), sleep 1s to avoid hot-looping.

  • [ ] Step 1: Failing tests (miniredis + TestPool):
  • TestIngestsHistoryAndAcks: XADD 3 valid events (marshal real store.Event values with distinct ULID event_ids) BEFORE starting Run; run with cancellable ctx; poll until 3 rows in ai_calls; XPENDING == 0.
  • TestPoisonMessageAckedAndCounted: XADD garbage json + 1 valid; both end acked, 1 row inserted, parse_errors_total == 1 (read via prometheus/client_golang/prometheus/testutil.ToFloat64).
  • TestRedeliveryDedups: XADD same event twice → 1 row, deduped_total == 1, both acked.
  • [ ] Step 2: Verify failure → implement → pass (-race).
  • [ ] Step 3: Commitfeat(ingest): consumer-group ingester with dedup, poison handling, autoclaim.

Task 6: Query API — auth middleware + read endpoints

Files:
- Create: internal/api/server.go
- Test: internal/api/server_test.go

Interfaces:
- Produces: api.New(cfg *config.Config, pool *pgxpool.Pool, reg *prometheus.Registry) *Server with Handler() http.Handler.
- Routes and role gates (bearer token → role via cfg.Tokens; missing/unknown token → 401; insufficient role → 403):

Route Min role
GET /api/summary?from&to executive
GET /api/series?from&to&bucket&group_by executive
GET /api/calls?engine&upstream&status&trace_id&from&to&limit&before_ts&before_id operator
GET /api/calls/{event_id} operator
GET /api/calls/{event_id}/body auditor
GET /api/verify-chain?from&to (chain_pos bounds) auditor
GET /api/access-log?limit auditor
GET /healthz (no auth) · GET /metrics (no auth)

Role ordering: executive < operator < auditor is FALSE — roles are not strictly nested for reads of bodies vs ops. Define explicit allow-sets: executive={summary,series}; operator=executive∪{calls,call}; auditor=operator∪{body,verify-chain,access-log}.
- /body behavior: fetch via store.GetBody; first store.LogBodyAccess(actor=token-name…, role, eventID, "view") (actor = first 8 chars of sha256(token), not the token itself), then decompress both parts with zstd and return {"event_id":…, "request": "<utf8 string>", "response": "<utf8>", "body_truncated_note": present-when-metadata-says-truncated}. Decompress failure → 500 with clear code but the access is still logged.
- All JSON responses set Content-Type; errors are {"error":{"code":…,"message":…}} (match the gateway's shape).

  • [ ] Step 1: Failing tests: TestAuthMatrix (table-driven: each route × {no token, exec, operator, auditor} → expected status), TestBodyEndpointLogsAccessAndDecompresses (insert event with zstd bodies via store, GET body as auditor → plaintext matches, one access-log row exists; GET as operator → 403 AND no access-log row), TestSummaryAndSeriesReturnData (insert 3 events, summary counts match; series with bucket=1m group_by=engine non-empty; group_by=evil → 400).
  • [ ] Step 2: Verify failure → implement → pass. Use a per-server prometheus.Registry (same reasoning as the gateway: multiple Servers per test process).
  • [ ] Step 3: Commitfeat(api): role-gated query API with logged body access and chain verification.

Task 7: Assembly — main.go, healthz, graceful shutdown

Files:
- Create: cmd/observatory/main.go
- Test: extend internal/api/server_test.go with TestHealthz

Interfaces: main wiring: config from OBSERVATORY_CONFIG (default /etc/ahu-observatory/observatory.yaml) → pgxpool → store.Migrate (fatal on error) → redis client → ingest.New(...).Run in goroutine → api.New on cfg.Listen with ReadHeaderTimeout: 10s → SIGTERM/SIGINT: cancel ingester ctx, http.Server.Shutdown with 15s budget. /healthz returns {"status":"ok","pending":N,"ingested":N} (503 if Postgres ping fails — Redis outage is degraded-but-alive: ingester retries, API still serves).

  • [ ] Implement, go build ./cmd/observatory, go vet ./..., full suite, commit — feat(main): assembly with graceful shutdown and health reporting.

Task 8: Packaging — Dockerfile, compose, example config, README

Files:
- Create: Dockerfile, deploy/compose.yaml, deploy/observatory.example.yaml, README.md

Dockerfile: same shape as the gateway's (golang:1.25-alpine builder → alpine:3.20, non-root, static build, EXPOSE 8300).

deploy/observatory.example.yaml:

listen: ":8300"
pg_dsn: "postgres://observatory:observatory@ahu-observatory-db:5432/observatory"
redis_url: "redis://ahu-platform-redis:6379/0"
stream_key: "ahu.ai.audit"
consumer_group: "observatory"
retention_days: 400
tokens:
  # replace before real use; roles: executive | operator | auditor
  tok-exec-CHANGEME: executive
  tok-ops-CHANGEME: operator
  tok-aud-CHANGEME: auditor

deploy/compose.yaml:

name: ahu-observatory
services:
  observatory:
    build: ..
    restart: unless-stopped
    ports: ["8300:8300"]
    environment:
      OBSERVATORY_CONFIG: /etc/ahu-observatory/observatory.yaml
    volumes:
      - ./observatory.yaml:/etc/ahu-observatory/observatory.yaml:ro
    depends_on: [db]
    networks: [default, platform]
  db:
    image: timescale/timescaledb:latest-pg16
    container_name: ahu-observatory-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: observatory
      POSTGRES_PASSWORD: observatory
      POSTGRES_DB: observatory
    volumes: [obs-pg-data:/var/lib/postgresql/data]
    networks: [default]
networks:
  platform:
    external: true
    name: ahu-platform_default
volumes:
  obs-pg-data:

(The external ahu-platform_default network is how the observatory reaches ahu-platform-redis — the gateway stack's Redis is not host-published. Verify the network name with docker network ls on the target host during Task 9 and adjust if compose named it differently.)

README.md ≤80 lines: quickstart, role model, pointer table (CONVENTIONS, spec §5, gateway repo). Upload after writing.

  • [ ] Build image, full suite, commit — feat(deploy): dockerfile, compose (platform-net), example config, README.

Task 9: Live-fire on ai-ahu — ingest the real audit stream (P1 exit)

No new production code; fixes get micro-commits.

  • [ ] Step 1: rsync repo to efran@192.168.83.20:~/ahu-ai-observatory/ (exclude .git/.superpowers), cp deploy/observatory.example.yaml deploy/observatory.yaml there and replace the three CHANGEME tokens with generated ones (openssl rand -hex 16 each; record them in the report file, NOT in git).
  • [ ] Step 2: verify the platform network name on the host (docker network ls | grep platform), adjust deploy/observatory compose if needed, then docker compose up -d --build and curl -sf localhost:8300/healthz.
  • [ ] Step 3: THE REAL TEST — the stream already contains genuine events (real chatbot traffic). Within ~10s of startup the ingester must have consumed the full history: curl -sf -H "Authorization: Bearer <ops-token>" "localhost:8300/api/calls?limit=50" → returns the real events including engine=ahu-chatbot rows with model Qwen/Qwen3.6-35B-A3B-FP8 AND the ext-dashscope synthesis call; row count == XLEN ahu.ai.audit; XPENDING ahu.ai.audit observatory == 0.
  • [ ] Step 4: GET /api/calls/{event_id}/body (auditor token) for one real chatbot event → decompressed prompt/response visible; GET /api/access-log shows exactly that view; GET /api/verify-chain{"first_bad":0}.
  • [ ] Step 5: generate one fresh event (curl one chat completion through the gateway) and confirm it appears in /api/calls within 5s (live tail path, not just history).
  • [ ] Step 6: kill-restart drill: docker compose restart observatory; confirm no duplicate rows (count unchanged apart from step-5 event), pending drains to 0.
  • [ ] Step 7: commit any fixes + git tag obs-p1; write drill report to .superpowers/sdd/task-9-report.md and upload it.

Self-review notes (performed at plan time)

  • Spec §5 coverage: 5.1 event schema consumption → Task 4 Event mirror + Task 5 parse; 5.2 tiered storage → Tasks 2–4 (hypertable+aggregate / hash-chained bodies; retention policy is config-declared now, enforcement job deferred to backlog — partition-drop + chain checkpoint tooling arrives with the retention job, documented gap); 5.3 query API + RBAC + access-audit → Task 6; 5.5 self-monitoring → /healthz + /metrics (Tasks 5–7). 5.4 dashboard → plan 2.
  • Deliberate simplifications: single bodies table (monthly partitioning deferred until volume demands — chain logic is partition-agnostic since it orders by chain_pos); percentiles computed from raw rows at query time (toolkit dependency avoided); retention enforcement job deferred (config field exists, enforcement is backlog).
  • Type consistency: store.Event JSON tags match the gateway's audit.Event exactly (verified against ../ahu-gpu-manager/internal/audit/event.go field list incl. classify in operation domain); InsertBody used by InsertEvent (Task 4) matches Task 3 signature; api.New consumes config.Tokens map from Task 1.
  • Known risk called out in-plan: Timescale continuous-aggregate creation inside the migrator (Task 2 documents the two-step fallback).