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, branchfeat/observatory-p1— create it in Task 1). Module pathdatahive.id/ahu-ai-observatory. - Contract values from
../ahu-gpu-manager/docs/CONVENTIONS.mdv1.0 (on conflict it wins): stream keyahu.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_hashof 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 abody_access_auditrow 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 createsscripts/testdb.shto run it; tests skip with a clear message ifTEST_PG_DSNis 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
.mdfile gets uploaded:curl -F "file=@<file>" https://x056.think.val.id/upload. - Engine repos and
ahu-gpu-managerare read-only context — never modified by this plan. - All tests:
go test ./... -count=1;-raceon 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 failure —
go 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 failure —
go 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.Acquire → conn.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 failure —
go 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 tests —
internal/store/calls_test.gowith 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 + commit —
go test ./internal/store/ -v -race; commitfeat(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 realstore.Eventvalues 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 viaprometheus/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: Commit —
feat(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: Commit —
feat(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.yamlthere and replace the three CHANGEME tokens with generated ones (openssl rand -hex 16each; 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, thendocker compose up -d --buildandcurl -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-logshows 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/callswithin 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.mdand 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.EventJSON tags match the gateway'saudit.Eventexactly (verified against../ahu-gpu-manager/internal/audit/event.gofield list incl.classifyin 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).