File created successfully at: /Users/steve.briggs/projects/operations/services/televibe/internal/reputation/reputation.go

// Package reputation maintains a per-user reputation score in Teleport user
// traits based on evaluated access request risk. Scores are read and written
// as string traits so they surface to approvers in the Teleport UI.
package reputation

import (
	"context"
	"fmt"
	"strconv"
	"sync"
	"time"

	"github.com/gravitational/teleport/api/types"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
)

const (
	TraitScore     = "reputation_score"
	TraitUpdatedAt = "reputation_updated_at"
	TraitLastDelta = "reputation_last_delta"
)

// UserClient is the subset of the Teleport API the manager needs.
type UserClient interface {
	GetUser(ctx context.Context, name string, withSecrets bool) (types.User, error)
	UpdateUser(ctx context.Context, user types.User) (types.User, error)
}

// Update describes the result of applying an assessment to a user's score.
type Update struct {
	User     string
	Previous int
	Delta    int
	New      int
}

// Manager updates per-user reputation traits based on assessment outcomes.
type Manager struct {
	client UserClient
	cfg    config.Reputation
	now    func() time.Time

	mu    sync.Mutex
	locks map[string]*sync.Mutex
}

func New(client UserClient, cfg config.Reputation) *Manager {
	return &Manager{
		client: client,
		cfg:    cfg,
		now:    time.Now,
		locks:  make(map[string]*sync.Mutex),
	}
}

// Compute returns the new (clamped) score and the delta applied, given the
// previous score, the risk level of the assessment, and the reputation config.
// Exposed for unit tests and callers that want to preview the result.
func Compute(prev int, level evaluator.RiskLevel, cfg config.Reputation) (next, delta int) {
	switch level {
	case evaluator.RiskLow:
		delta = cfg.DeltaLow
	case evaluator.RiskHigh:
		delta = cfg.DeltaHigh
	default:
		delta = cfg.DeltaMedium
	}
	return clamp(prev+delta, cfg.Min, cfg.Max), delta
}

// Apply reads the user's current reputation, computes and writes the new
// score, and returns what changed. Concurrent Apply calls for the same user
// are serialized to avoid clobbering writes.
func (m *Manager) Apply(ctx context.Context, username string, a *evaluator.Assessment) (Update, error) {
	lock := m.userLock(username)
	lock.Lock()
	defer lock.Unlock()

	user, err := m.client.GetUser(ctx, username, false)
	if err != nil {
		return Update{}, fmt.Errorf("get user %q: %w", username, err)
	}

	traits := user.GetTraits()
	if traits == nil {
		traits = make(map[string][]string)
	}

	prev := m.cfg.Start
	if existing, ok := firstTrait(traits, TraitScore); ok {
		if n, err := strconv.Atoi(existing); err == nil {
			prev = clamp(n, m.cfg.Min, m.cfg.Max)
		}
	}

	next, delta := Compute(prev, a.RiskLevel, m.cfg)

	traits[TraitScore] = []string{strconv.Itoa(next)}
	traits[TraitUpdatedAt] = []string{m.now().UTC().Format(time.RFC3339)}
	traits[TraitLastDelta] = []string{strconv.Itoa(delta)}
	user.SetTraits(traits)

	if _, err := m.client.UpdateUser(ctx, user); err != nil {
		return Update{}, fmt.Errorf("update user %q: %w", username, err)
	}

	return Update{User: username, Previous: prev, Delta: delta, New: next}, nil
}

func (m *Manager) userLock(user string) *sync.Mutex {
	m.mu.Lock()
	defer m.mu.Unlock()
	l, ok := m.locks[user]
	if !ok {
		l = &sync.Mutex{}
		m.locks[user] = l
	}
	return l
}

func firstTrait(t map[string][]string, key string) (string, bool) {
	v, ok := t[key]
	if !ok || len(v) == 0 {
		return "", false
	}
	return v[0], true
}

func clamp(n, lo, hi int) int {
	if n < lo {
		return lo
	}
	if n > hi {
		return hi
	}
	return n
}


Write

package reputation

import (
	"context"
	"errors"
	"strconv"
	"sync"
	"testing"
	"time"

	"github.com/gravitational/teleport/api/types"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func defaultCfg() config.Reputation {
	return config.Reputation{
		DeltaLow:    1,
		DeltaMedium: 0,
		DeltaHigh:   -2,
		Min:         -20,
		Max:         20,
		Start:       0,
	}
}

func TestCompute(t *testing.T) {
	cfg := defaultCfg()

	tests := []struct {
		name      string
		prev      int
		level     evaluator.RiskLevel
		wantNext  int
		wantDelta int
	}{
		{"low adds DeltaLow", 5, evaluator.RiskLow, 6, 1},
		{"medium no-op by default", 5, evaluator.RiskMedium, 5, 0},
		{"high subtracts", 5, evaluator.RiskHigh, 3, -2},
		{"clamp at max", 20, evaluator.RiskLow, 20, 1},
		{"clamp at min", -20, evaluator.RiskHigh, -20, -2},
		{"unknown level treated as medium", -3, evaluator.RiskLevel("weird"), -3, 0},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			next, delta := Compute(tt.prev, tt.level, cfg)
			assert.Equal(t, tt.wantNext, next, "next score")
			assert.Equal(t, tt.wantDelta, delta, "delta applied")
		})
	}
}

// fakeUserClient captures GetUser/UpdateUser calls against an in-memory map.
type fakeUserClient struct {
	mu      sync.Mutex
	users   map[string]types.User
	getErr  error
	putErr  error
	updates int
}

func newFakeUserClient(t *testing.T, existing map[string]map[string][]string) *fakeUserClient {
	t.Helper()
	f := &fakeUserClient{users: make(map[string]types.User)}
	for name, traits := range existing {
		u, err := types.NewUser(name)
		require.NoError(t, err)
		if traits != nil {
			u.SetTraits(traits)
		}
		f.users[name] = u
	}
	return f
}

func (f *fakeUserClient) GetUser(_ context.Context, name string, _ bool) (types.User, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.getErr != nil {
		return nil, f.getErr
	}
	u, ok := f.users[name]
	if !ok {
		return nil, errors.New("user not found")
	}
	return u, nil
}

func (f *fakeUserClient) UpdateUser(_ context.Context, u types.User) (types.User, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.putErr != nil {
		return nil, f.putErr
	}
	f.users[u.GetName()] = u
	f.updates++
	return u, nil
}

func TestManager_Apply_NewUserStartsAtStart(t *testing.T) {
	cfg := defaultCfg()
	cfg.Start = 3

	client := newFakeUserClient(t, map[string]map[string][]string{"alice": nil})
	m := New(client, cfg)
	m.now = func() time.Time { return time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC) }

	got, err := m.Apply(context.Background(), "alice", &evaluator.Assessment{RiskLevel: evaluator.RiskHigh})
	require.NoError(t, err)

	assert.Equal(t, Update{User: "alice", Previous: 3, Delta: -2, New: 1}, got)

	traits := client.users["alice"].GetTraits()
	assert.Equal(t, []string{"1"}, traits[TraitScore])
	assert.Equal(t, []string{"-2"}, traits[TraitLastDelta])
	assert.Equal(t, []string{"2026-04-23T12:00:00Z"}, traits[TraitUpdatedAt])
}

func TestManager_Apply_ExistingScoreRead(t *testing.T) {
	cfg := defaultCfg()
	client := newFakeUserClient(t, map[string]map[string][]string{
		"bob": {TraitScore: {"-5"}},
	})
	m := New(client, cfg)

	got, err := m.Apply(context.Background(), "bob", &evaluator.Assessment{RiskLevel: evaluator.RiskLow})
	require.NoError(t, err)
	assert.Equal(t, -5, got.Previous)
	assert.Equal(t, 1, got.Delta)
	assert.Equal(t, -4, got.New)
}

func TestManager_Apply_CorruptedScoreResetsToStart(t *testing.T) {
	cfg := defaultCfg()
	cfg.Start = 7
	client := newFakeUserClient(t, map[string]map[string][]string{
		"carol": {TraitScore: {"not-a-number"}},
	})
	m := New(client, cfg)

	got, err := m.Apply(context.Background(), "carol", &evaluator.Assessment{RiskLevel: evaluator.RiskLow})
	require.NoError(t, err)
	assert.Equal(t, 7, got.Previous, "should fall back to Start when trait unparseable")
	assert.Equal(t, 8, got.New)
}

func TestManager_Apply_ClampsExistingOutOfRangeScore(t *testing.T) {
	cfg := defaultCfg() // [-20, 20]
	client := newFakeUserClient(t, map[string]map[string][]string{
		"dave": {TraitScore: {"9999"}},
	})
	m := New(client, cfg)

	got, err := m.Apply(context.Background(), "dave", &evaluator.Assessment{RiskLevel: evaluator.RiskHigh})
	require.NoError(t, err)
	assert.Equal(t, 20, got.Previous, "stored score outside range is clamped on read")
	assert.Equal(t, 18, got.New)
}

func TestManager_Apply_PropagatesGetError(t *testing.T) {
	client := newFakeUserClient(t, nil)
	client.getErr = errors.New("teleport down")
	m := New(client, defaultCfg())

	_, err := m.Apply(context.Background(), "eve", &evaluator.Assessment{RiskLevel: evaluator.RiskLow})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "teleport down")
}

func TestManager_Apply_PropagatesUpdateError(t *testing.T) {
	client := newFakeUserClient(t, map[string]map[string][]string{"frank": nil})
	client.putErr = errors.New("write rejected")
	m := New(client, defaultCfg())

	_, err := m.Apply(context.Background(), "frank", &evaluator.Assessment{RiskLevel: evaluator.RiskLow})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "write rejected")
}

// Concurrent Apply calls for the same user must not clobber each other:
// each call must see the effect of the previous one.
func TestManager_Apply_SerializesPerUser(t *testing.T) {
	cfg := defaultCfg()
	client := newFakeUserClient(t, map[string]map[string][]string{"grace": nil})
	m := New(client, cfg)

	const n = 50
	var wg sync.WaitGroup
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_, err := m.Apply(context.Background(), "grace", &evaluator.Assessment{RiskLevel: evaluator.RiskLow})
			assert.NoError(t, err)
		}()
	}
	wg.Wait()

	traits := client.users["grace"].GetTraits()
	got, err := strconv.Atoi(traits[TraitScore][0])
	require.NoError(t, err)
	// Start is 0, each call adds DeltaLow=1, clamp at Max=20.
	assert.Equal(t, cfg.Max, got)
	assert.Equal(t, n, client.updates)
}


File created successfully at: /Users/steve.briggs/projects/operations/services/televibe/internal/reputation/reputation_test.go

package reputation

import (
	"context"
	"errors"
	"strconv"
	"sync"
	"testing"
	"time"

	"github.com/gravitational/teleport/api/types"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func defaultCfg() config.Reputation {
	return config.Reputation{
		DeltaLow:    1,
		DeltaMedium: 0,
		DeltaHigh:   -2,
		Min:         -20,
		Max:         20,
		Start:       0,
	}
}

func TestCompute(t *testing.T) {
	cfg := defaultCfg()

	tests := []struct {
		name      string
		prev      int
		level     evaluator.RiskLevel
		wantNext  int
		wantDelta int
	}{
		{"low adds DeltaLow", 5, evaluator.RiskLow, 6, 1},
		{"medium no-op by default", 5, evaluator.RiskMedium, 5, 0},
		{"high subtracts", 5, evaluator.RiskHigh, 3, -2},
		{"clamp at max", 20, evaluator.RiskLow, 20, 1},
		{"clamp at min", -20, evaluator.RiskHigh, -20, -2},
		{"unknown level treated as medium", -3, evaluator.RiskLevel("weird"), -3, 0},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			next, delta := Compute(tt.prev, tt.level, cfg)
			assert.Equal(t, tt.wantNext, next, "next score")
			assert.Equal(t, tt.wantDelta, delta, "delta applied")
		})
	}
}

// fakeUserClient captures GetUser/UpdateUser calls against an in-memory map.
type fakeUserClient struct {
	mu      sync.Mutex
	users   map[string]types.User
	getErr  error
	putErr  error
	updates int
}

func newFakeUserClient(t *testing.T, existing map[string]map[string][]string) *fakeUserClient {
	t.Helper()
	f := &fakeUserClient{users: make(map[string]types.User)}
	for name, traits := range existing {
		u, err := types.NewUser(name)
		require.NoError(t, err)
		if traits != nil {
			u.SetTraits(traits)
		}
		f.users[name] = u
	}
	return f
}

func (f *fakeUserClient) GetUser(_ context.Context, name string, _ bool) (types.User, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.getErr != nil {
		return nil, f.getErr
	}
	u, ok := f.users[name]
	if !ok {
		return nil, errors.New("user not found")
	}
	return u, nil
}

func (f *fakeUserClient) UpdateUser(_ context.Context, u types.User) (types.User, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.putErr != nil {
		return nil, f.putErr
	}
	f.users[u.GetName()] = u
	f.updates++
	return u, nil
}

func TestManager_Apply_NewUserStartsAtStart(t *testing.T) {
	cfg := defaultCfg()
	cfg.Start = 3

	client := newFakeUserClient(t, map[string]map[string][]string{"alice": nil})
	m := New(client, cfg)
	m.now = func() time.Time { return time.Date(2026, 4, 23, 12, 0, 0, 0, time.UTC) }

	got, err := m.Apply(context.Background(), "alice", &evaluator.Assessment{RiskLevel: evaluator.RiskHigh})
	require.NoError(t, err)

	assert.Equal(t, Update{User: "alice", Previous: 3, Delta: -2, New: 1}, got)

	traits := client.users["alice"].GetTraits()
	assert.Equal(t, []string{"1"}, traits[TraitScore])
	assert.Equal(t, []string{"-2"}, traits[TraitLastDelta])
	assert.Equal(t, []string{"2026-04-23T12:00:00Z"}, traits[TraitUpdatedAt])
}

func TestManager_Apply_ExistingScoreRead(t *testing.T) {
	cfg := defaultCfg()
	client := newFakeUserClient(t, map[string]map[string][]string{
		"bob": {TraitScore: {"-5"}},
	})
	m := New(client, cfg)

	got, err := m.Apply(context.Background(), "bob", &evaluator.Assessment{RiskLevel: evaluator.RiskLow})
	require.NoError(t, err)
	assert.Equal(t, -5, got.Previous)
	assert.Equal(t, 1, got.Delta)
	assert.Equal(t, -4, got.New)
}

func TestManager_Apply_CorruptedScoreResetsToStart(t *testing.T) {
	cfg := defaultCfg()
	cfg.Start = 7
	client := newFakeUserClient(t, map[string]map[string][]string{
		"carol": {TraitScore: {"not-a-number"}},
	})
	m := New(client, cfg)

	got, err := m.Apply(context.Background(), "carol", &evaluator.Assessment{RiskLevel: evaluator.RiskLow})
	require.NoError(t, err)
	assert.Equal(t, 7, got.Previous, "should fall back to Start when trait unparseable")
	assert.Equal(t, 8, got.New)
}

func TestManager_Apply_ClampsExistingOutOfRangeScore(t *testing.T) {
	cfg := defaultCfg() // [-20, 20]
	client := newFakeUserClient(t, map[string]map[string][]string{
		"dave": {TraitScore: {"9999"}},
	})
	m := New(client, cfg)

	got, err := m.Apply(context.Background(), "dave", &evaluator.Assessment{RiskLevel: evaluator.RiskHigh})
	require.NoError(t, err)
	assert.Equal(t, 20, got.Previous, "stored score outside range is clamped on read")
	assert.Equal(t, 18, got.New)
}

func TestManager_Apply_PropagatesGetError(t *testing.T) {
	client := newFakeUserClient(t, nil)
	client.getErr = errors.New("teleport down")
	m := New(client, defaultCfg())

	_, err := m.Apply(context.Background(), "eve", &evaluator.Assessment{RiskLevel: evaluator.RiskLow})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "teleport down")
}

func TestManager_Apply_PropagatesUpdateError(t *testing.T) {
	client := newFakeUserClient(t, map[string]map[string][]string{"frank": nil})
	client.putErr = errors.New("write rejected")
	m := New(client, defaultCfg())

	_, err := m.Apply(context.Background(), "frank", &evaluator.Assessment{RiskLevel: evaluator.RiskLow})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "write rejected")
}

// Concurrent Apply calls for the same user must not clobber each other:
// each call must see the effect of the previous one.
func TestManager_Apply_SerializesPerUser(t *testing.T) {
	cfg := defaultCfg()
	client := newFakeUserClient(t, map[string]map[string][]string{"grace": nil})
	m := New(client, cfg)

	const n = 50
	var wg sync.WaitGroup
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			_, err := m.Apply(context.Background(), "grace", &evaluator.Assessment{RiskLevel: evaluator.RiskLow})
			assert.NoError(t, err)
		}()
	}
	wg.Wait()

	traits := client.users["grace"].GetTraits()
	got, err := strconv.Atoi(traits[TraitScore][0])
	require.NoError(t, err)
	// Start is 0, each call adds DeltaLow=1, clamp at Max=20.
	assert.Equal(t, cfg.Max, got)
	assert.Equal(t, n, client.updates)
}


1. yes, 2. the OTLP endpoint configuration should use the env var, typically on localhost:4317 but if no env var is set just disable OTLP shipping. 3. yes that is fine. 4. the deltas look good but let…


Updated task #3 status


Updated task #2 status


Bash

mise exec -- go doc go.opentelemetry.io/otel/metric.Meter 2>&1 | grep -E "Int64Gauge|Int64Histogram|Int64Counter" | head -10

Verify Meter has Int64Gauge


	// Int64Counter returns a new Int64Counter instrument identified by name
	Int64Counter(name string, options ...Int64CounterOption) (Int64Counter, error)
	// Int64Histogram returns a new Int64Histogram instrument identified by
	Int64Histogram(name string, options ...Int64HistogramOption) (Int64Histogram, error)
	// Int64Gauge returns a new Int64Gauge instrument identified by name and
	Int64Gauge(name string, options ...Int64GaugeOption) (Int64Gauge, error)


mise exec -- go doc go.opentelemetry.io/otel/metric/noop 2>&1 | head -15 && echo "---semconv---" && mise exec -- go list -m -versions go.opentelemetry.io/otel/semconv/v1.26.0 2>&1 | head -3 && echo "---sdkmetric---" && mise exec -- go doc go.opentelemetry.io/otel/sdk/metric.NewPeriodicReader 2>&1 | head -5

Check noop meter and semconv availability


package noop // import "go.opentelemetry.io/otel/metric/noop"

Package noop provides an implementation of the OpenTelemetry metric API that
produces no telemetry and minimizes used computation resources.

Using this package to implement the OpenTelemetry metric API will effectively
disable OpenTelemetry.

This implementation can be embedded in other implementations of the
OpenTelemetry metric API. Doing so will mean the implementation defaults to no
operation for methods it does not implement.

type Float64Counter struct{ ... }
type Float64Gauge struct{ ... }
type Float64Histogram struct{ ... }
---semconv---
go: malformed module path "go.opentelemetry.io/otel/semconv/v1.26.0": invalid version
---sdkmetric---
package metric // import "go.opentelemetry.io/otel/sdk/metric"

func NewPeriodicReader(exporter Exporter, options ...PeriodicReaderOption) *PeriodicReader
    NewPeriodicReader returns a Reader that collects and exports metric data
    to the exporter at a defined interval. By default, the returned Reader will


mise exec -- go list -m all 2>&1 | grep -E "semconv|otlpmetric" | head -10

Check semconv/otlpmetric module availability


(Bash completed with no output)


mise exec -- go list -m all 2>&1 | grep -iE "semconv|otlpmetric|otlp" | head -20

Check what OTel exporter modules are available


go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/proto/otlp v1.10.0


Write

// Package telemetry wires up an OTel meter provider. When OTLP is disabled
// (empty endpoint), a no-op provider is returned so record calls remain safe
// and cheap throughout the hot path.
package telemetry

import (
	"context"
	"fmt"

	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
	"go.opentelemetry.io/otel/metric"
	"go.opentelemetry.io/otel/metric/noop"
	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/resource"
)

const serviceName = "televibe"

// Instruments are the metric handles recorded on the evaluate path.
type Instruments struct {
	RiskScore       metric.Int64Histogram
	ReputationScore metric.Int64Gauge
	Adjustments     metric.Int64Counter
}

// Metrics owns the meter provider lifecycle plus the instruments. Shutdown
// flushes and releases the exporter; safe to call on a no-op provider.
type Metrics struct {
	Instruments Instruments
	Shutdown    func(context.Context) error
}

// New builds metric instruments. If endpoint is empty, a no-op meter is used
// and no OTLP connection is opened. Otherwise, OTLP gRPC metrics ship to
// endpoint (insecure; intended for an in-cluster collector on localhost:4317
// or similar).
func New(ctx context.Context, endpoint string) (*Metrics, error) {
	if endpoint == "" {
		instr, err := buildInstruments(noop.NewMeterProvider().Meter(serviceName))
		if err != nil {
			return nil, err
		}
		return &Metrics{
			Instruments: instr,
			Shutdown:    func(context.Context) error { return nil },
		}, nil
	}

	exp, err := otlpmetricgrpc.New(ctx,
		otlpmetricgrpc.WithEndpoint(endpoint),
		otlpmetricgrpc.WithInsecure(),
	)
	if err != nil {
		return nil, fmt.Errorf("otlp metric exporter: %w", err)
	}

	res, err := resource.New(ctx, resource.WithAttributes(
		attribute.String("service.name", serviceName),
	))
	if err != nil {
		return nil, fmt.Errorf("resource: %w", err)
	}

	provider := sdkmetric.NewMeterProvider(
		sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp)),
		sdkmetric.WithResource(res),
	)

	instr, err := buildInstruments(provider.Meter(serviceName))
	if err != nil {
		// Best-effort cleanup of the exporter if instrument setup fails.
		_ = provider.Shutdown(ctx)
		return nil, err
	}

	return &Metrics{
		Instruments: instr,
		Shutdown:    provider.Shutdown,
	}, nil
}

func buildInstruments(m metric.Meter) (Instruments, error) {
	riskScore, err := m.Int64Histogram(
		"televibe.assessment.risk_score",
		metric.WithDescription("Risk score assigned by the evaluator (1-10)"),
	)
	if err != nil {
		return Instruments{}, fmt.Errorf("risk_score histogram: %w", err)
	}

	repScore, err := m.Int64Gauge(
		"televibe.reputation.score",
		metric.WithDescription("Current reputation score for a user after the latest assessment"),
	)
	if err != nil {
		return Instruments{}, fmt.Errorf("reputation.score gauge: %w", err)
	}

	adjustments, err := m.Int64Counter(
		"televibe.reputation.adjustments",
		metric.WithDescription("Count of reputation adjustments applied, tagged by risk_level"),
	)
	if err != nil {
		return Instruments{}, fmt.Errorf("adjustments counter: %w", err)
	}

	return Instruments{
		RiskScore:       riskScore,
		ReputationScore: repScore,
		Adjustments:     adjustments,
	}, nil
}


File created successfully at: /Users/steve.briggs/projects/operations/services/televibe/internal/telemetry/telemetry.go

// Package telemetry wires up an OTel meter provider. When OTLP is disabled
// (empty endpoint), a no-op provider is returned so record calls remain safe
// and cheap throughout the hot path.
package telemetry

import (
	"context"
	"fmt"

	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
	"go.opentelemetry.io/otel/metric"
	"go.opentelemetry.io/otel/metric/noop"
	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/resource"
)

const serviceName = "televibe"

// Instruments are the metric handles recorded on the evaluate path.
type Instruments struct {
	RiskScore       metric.Int64Histogram
	ReputationScore metric.Int64Gauge
	Adjustments     metric.Int64Counter
}

// Metrics owns the meter provider lifecycle plus the instruments. Shutdown
// flushes and releases the exporter; safe to call on a no-op provider.
type Metrics struct {
	Instruments Instruments
	Shutdown    func(context.Context) error
}

// New builds metric instruments. If endpoint is empty, a no-op meter is used
// and no OTLP connection is opened. Otherwise, OTLP gRPC metrics ship to
// endpoint (insecure; intended for an in-cluster collector on localhost:4317
// or similar).
func New(ctx context.Context, endpoint string) (*Metrics, error) {
	if endpoint == "" {
		instr, err := buildInstruments(noop.NewMeterProvider().Meter(serviceName))
		if err != nil {
			return nil, err
		}
		return &Metrics{
			Instruments: instr,
			Shutdown:    func(context.Context) error { return nil },
		}, nil
	}

	exp, err := otlpmetricgrpc.New(ctx,
		otlpmetricgrpc.WithEndpoint(endpoint),
		otlpmetricgrpc.WithInsecure(),
	)
	if err != nil {
		return nil, fmt.Errorf("otlp metric exporter: %w", err)
	}

	res, err := resource.New(ctx, resource.WithAttributes(
		attribute.String("service.name", serviceName),
	))
	if err != nil {
		return nil, fmt.Errorf("resource: %w", err)
	}

	provider := sdkmetric.NewMeterProvider(
		sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp)),
		sdkmetric.WithResource(res),
	)

	instr, err := buildInstruments(provider.Meter(serviceName))
	if err != nil {
		// Best-effort cleanup of the exporter if instrument setup fails.
		_ = provider.Shutdown(ctx)
		return nil, err
	}

	return &Metrics{
		Instruments: instr,
		Shutdown:    provider.Shutdown,
	}, nil
}

func buildInstruments(m metric.Meter) (Instruments, error) {
	riskScore, err := m.Int64Histogram(
		"televibe.assessment.risk_score",
		metric.WithDescription("Risk score assigned by the evaluator (1-10)"),
	)
	if err != nil {
		return Instruments{}, fmt.Errorf("risk_score histogram: %w", err)
	}

	repScore, err := m.Int64Gauge(
		"televibe.reputation.score",
		metric.WithDescription("Current reputation score for a user after the latest assessment"),
	)
	if err != nil {
		return Instruments{}, fmt.Errorf("reputation.score gauge: %w", err)
	}

	adjustments, err := m.Int64Counter(
		"televibe.reputation.adjustments",
		metric.WithDescription("Count of reputation adjustments applied, tagged by risk_level"),
	)
	if err != nil {
		return Instruments{}, fmt.Errorf("adjustments counter: %w", err)
	}

	return Instruments{
		RiskScore:       riskScore,
		ReputationScore: repScore,
		Adjustments:     adjustments,
	}, nil
}


package telemetry

import (
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/metric"
)

func TestNew_NoopWhenEndpointEmpty(t *testing.T) {
	m, err := New(context.Background(), "")
	require.NoError(t, err)
	require.NotNil(t, m)

	// Instruments should be valid (non-nil) and callable without panicking.
	m.Instruments.RiskScore.Record(context.Background(), 5, metric.WithAttributes(attribute.String("risk_level", "low")))
	m.Instruments.ReputationScore.Record(context.Background(), 3, metric.WithAttributes(attribute.String("user", "alice")))
	m.Instruments.Adjustments.Add(context.Background(), 1, metric.WithAttributes(attribute.String("risk_level", "high")))

	// Shutdown on noop should return nil and be safe.
	assert.NoError(t, m.Shutdown(context.Background()))
}


File created successfully at: /Users/steve.briggs/projects/operations/services/televibe/internal/telemetry/telemetry_test.go

package telemetry

import (
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/metric"
)

func TestNew_NoopWhenEndpointEmpty(t *testing.T) {
	m, err := New(context.Background(), "")
	require.NoError(t, err)
	require.NotNil(t, m)

	// Instruments should be valid (non-nil) and callable without panicking.
	m.Instruments.RiskScore.Record(context.Background(), 5, metric.WithAttributes(attribute.String("risk_level", "low")))
	m.Instruments.ReputationScore.Record(context.Background(), 3, metric.WithAttributes(attribute.String("user", "alice")))
	m.Instruments.Adjustments.Add(context.Background(), 1, metric.WithAttributes(attribute.String("risk_level", "high")))

	// Shutdown on noop should return nil and be safe.
	assert.NoError(t, m.Shutdown(context.Background()))
}


Updated task #4 status


Now to wire everything. The reputation manager needs the Teleport client too, so I'll lift client construction into main and have the watcher accept an already-built client.


package watcher

import (
	"context"
	"fmt"
	"log/slog"
	"time"

	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
)

const reconnectDelay = 5 * time.Second

// Watcher streams pending Teleport access requests onto a channel.
// It reconnects automatically on watcher failure. The Teleport client
// lifecycle is owned by the caller.
type Watcher struct {
	client   *client.Client
	requests chan evaluator.Request
	logger   *slog.Logger
}

// New constructs a Teleport client from the identity file at addr.
func NewClient(ctx context.Context, addr, identityFile string) (*client.Client, error) {
	clt, err := client.New(ctx, client.Config{
		Addrs: []string{addr},
		Credentials: []client.Credentials{
			client.LoadIdentityFile(identityFile),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("connecting to teleport: %w", err)
	}
	return clt, nil
}

// New starts a watcher against the supplied Teleport client.
func New(ctx context.Context, clt *client.Client, logger *slog.Logger) *Watcher {
	w := &Watcher{
		client:   clt,
		requests: make(chan evaluator.Request, 32),
		logger:   logger,
	}

	go w.watch(ctx)
	return w
}

// Requests returns the channel of incoming pending access requests.
func (w *Watcher) Requests() <-chan evaluator.Request {
	return w.requests
}

func (w *Watcher) watch(ctx context.Context) {
	defer close(w.requests)

	for {
		if err := w.watchOnce(ctx); err != nil {
			if ctx.Err() != nil {
				return
			}
			w.logger.Error("watcher disconnected, reconnecting", "error", err, "delay", reconnectDelay)
			select {
			case <-time.After(reconnectDelay):
			case <-ctx.Done():
				return
			}
		}
	}
}

func (w *Watcher) watchOnce(ctx context.Context) error {
	watcher, err := w.client.NewWatcher(ctx, types.Watch{
		Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}},
	})
	if err != nil {
		return fmt.Errorf("creating watcher: %w", err)
	}
	defer watcher.Close()

	// The first event must be OpInit confirming the watch is established.
	select {
	case event := <-watcher.Events():
		if event.Type != types.OpInit {
			return fmt.Errorf("expected OpInit, got %v", event.Type)
		}
		w.logger.Info("teleport watcher initialized")
	case <-watcher.Done():
		return fmt.Errorf("watcher closed during init: %w", watcher.Error())
	case <-ctx.Done():
		return ctx.Err()
	}

	for {
		select {
		case event := <-watcher.Events():
			if event.Type != types.OpPut {
				continue
			}
			req, ok := event.Resource.(types.AccessRequest)
			if !ok {
				continue
			}
			// Only forward newly-pending requests; ignore state transitions (approve/deny).
			if req.GetState() != types.RequestState_PENDING {
				continue
			}
			select {
			case w.requests <- toRequest(req):
			case <-ctx.Done():
				return ctx.Err()
			}
		case <-watcher.Done():
			return fmt.Errorf("watcher closed: %w", watcher.Error())
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

func toRequest(req types.AccessRequest) evaluator.Request {
	return evaluator.Request{
		ID:     req.GetName(),
		User:   req.GetUser(),
		Roles:  req.GetRoles(),
		Reason: req.GetRequestReason(),
	}
}


The file /Users/steve.briggs/projects/operations/services/televibe/internal/watcher/watcher.go has been updated successfully.

package watcher

import (
	"context"
	"fmt"
	"log/slog"
	"time"

	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
)

const reconnectDelay = 5 * time.Second

// Watcher streams pending Teleport access requests onto a channel.
// It reconnects automatically on watcher failure. The Teleport client
// lifecycle is owned by the caller.
type Watcher struct {
	client   *client.Client
	requests chan evaluator.Request
	logger   *slog.Logger
}

// New constructs a Teleport client from the identity file at addr.
func NewClient(ctx context.Context, addr, identityFile string) (*client.Client, error) {
	clt, err := client.New(ctx, client.Config{
		Addrs: []string{addr},
		Credentials: []client.Credentials{
			client.LoadIdentityFile(identityFile),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("connecting to teleport: %w", err)
	}
	return clt, nil
}

// New starts a watcher against the supplied Teleport client.
func New(ctx context.Context, clt *client.Client, logger *slog.Logger) *Watcher {
	w := &Watcher{
		client:   clt,
		requests: make(chan evaluator.Request, 32),
		logger:   logger,
	}

	go w.watch(ctx)
	return w
}

// Requests returns the channel of incoming pending access requests.
func (w *Watcher) Requests() <-chan evaluator.Request {
	return w.requests
}

func (w *Watcher) watch(ctx context.Context) {
	defer close(w.requests)

	for {
		if err := w.watchOnce(ctx); err != nil {
			if ctx.Err() != nil {
				return
			}
			w.logger.Error("watcher disconnected, reconnecting", "error", err, "delay", reconnectDelay)
			select {
			case <-time.After(reconnectDelay):
			case <-ctx.Done():
				return
			}
		}
	}
}

func (w *Watcher) watchOnce(ctx context.Context) error {
	watcher, err := w.client.NewWatcher(ctx, types.Watch{
		Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}},
	})
	if err != nil {
		return fmt.Errorf("creating watcher: %w", err)
	}
	defer watcher.Close()

	// The first event must be OpInit confirming the watch is established.
	select {
	case event := <-watcher.Events():
		if event.Type != types.OpInit {
			return fmt.Errorf("expected OpInit, got %v", event.Type)
		}
		w.logger.Info("teleport watcher initialized")
	case <-watcher.Done():
		return fmt.Errorf("watcher closed during init: %w", watcher.Error())
	case <-ctx.Done():
		return ctx.Err()
	}

	for {
		select {
		case event := <-watcher.Events():
			if event.Type != types.OpPut {
				continue
			}
			req, ok := event.Resource.(types.AccessRequest)
			if !ok {
				continue
			}
			// Only forward newly-pending requests; ignore state transitions (approve/deny).
			if req.GetState() != types.RequestState_PENDING {
				continue
			}
			select {
			case w.requests <- toRequest(req):
			case <-ctx.Done():
				return ctx.Err()
			}
		case <-watcher.Done():
			return fmt.Errorf("watcher closed: %w", watcher.Error())
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

func toRequest(req types.AccessRequest) evaluator.Request {
	return evaluator.Request{
		ID:     req.GetName(),
		User:   req.GetUser(),
		Roles:  req.GetRoles(),
		Reason: req.GetRequestReason(),
	}
}

-// It reconnects automatically on watcher failure.

+// It reconnects automatically on watcher failure. The Teleport client

-func New(ctx context.Context, addr, identityFile string, logger *slog.Logger) (*Watcher, error) {

+// New constructs a Teleport client from the identity file at addr.

+func NewClient(ctx context.Context, addr, identityFile string) (*client.Client, error) {

+// New starts a watcher against the supplied Teleport client.

+func New(ctx context.Context, clt *client.Client, logger *slog.Logger) *Watcher {

-// Close shuts down the Teleport client.

package watcher

import (
	"context"
	"fmt"
	"log/slog"
	"time"

	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
)

const reconnectDelay = 5 * time.Second

// Watcher streams pending Teleport access requests onto a channel.
// It reconnects automatically on watcher failure.
type Watcher struct {
	client   *client.Client
	requests chan evaluator.Request
	logger   *slog.Logger
}

func New(ctx context.Context, addr, identityFile string, logger *slog.Logger) (*Watcher, error) {
	clt, err := client.New(ctx, client.Config{
		Addrs: []string{addr},
		Credentials: []client.Credentials{
			client.LoadIdentityFile(identityFile),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("connecting to teleport: %w", err)
	}

	w := &Watcher{
		client:   clt,
		requests: make(chan evaluator.Request, 32),
		logger:   logger,
	}

	go w.watch(ctx)
	return w, nil
}

// Requests returns the channel of incoming pending access requests.
func (w *Watcher) Requests() <-chan evaluator.Request {
	return w.requests
}

// Close shuts down the Teleport client.
func (w *Watcher) Close() {
	w.client.Close()
}

func (w *Watcher) watch(ctx context.Context) {
	defer close(w.requests)

	for {
		if err := w.watchOnce(ctx); err != nil {
			if ctx.Err() != nil {
				return
			}
			w.logger.Error("watcher disconnected, reconnecting", "error", err, "delay", reconnectDelay)
			select {
			case <-time.After(reconnectDelay):
			case <-ctx.Done():
				return
			}
		}
	}
}

func (w *Watcher) watchOnce(ctx context.Context) error {
	watcher, err := w.client.NewWatcher(ctx, types.Watch{
		Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}},
	})
	if err != nil {
		return fmt.Errorf("creating watcher: %w", err)
	}
	defer watcher.Close()

	// The first event must be OpInit confirming the watch is established.
	select {
	case event := <-watcher.Events():
		if event.Type != types.OpInit {
			return fmt.Errorf("expected OpInit, got %v", event.Type)
		}
		w.logger.Info("teleport watcher initialized")
	case <-watcher.Done():
		return fmt.Errorf("watcher closed during init: %w", watcher.Error())
	case <-ctx.Done():
		return ctx.Err()
	}

	for {
		select {
		case event := <-watcher.Events():
			if event.Type != types.OpPut {
				continue
			}
			req, ok := event.Resource.(types.AccessRequest)
			if !ok {
				continue
			}
			// Only forward newly-pending requests; ignore state transitions (approve/deny).
			if req.GetState() != types.RequestState_PENDING {
				continue
			}
			select {
			case w.requests <- toRequest(req):
			case <-ctx.Done():
				return ctx.Err()
			}
		case <-watcher.Done():
			return fmt.Errorf("watcher closed: %w", watcher.Error())
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

func toRequest(req types.AccessRequest) evaluator.Request {
	return evaluator.Request{
		ID:     req.GetName(),
		User:   req.GetUser(),
		Roles:  req.GetRoles(),
		Reason: req.GetRequestReason(),
	}
}


package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"strconv"
	"syscall"
	"time"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/immuta/operations/services/televibe/internal/reputation"
	"github.com/immuta/operations/services/televibe/internal/telemetry"
	"github.com/immuta/operations/services/televibe/internal/watcher"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/metric"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("configuration error", "error", err)
		os.Exit(1)
	}

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	metrics, err := telemetry.New(ctx, cfg.OTLPEndpoint)
	if err != nil {
		logger.Error("telemetry init failed", "error", err)
		os.Exit(1)
	}
	defer func() {
		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer shutdownCancel()
		if err := metrics.Shutdown(shutdownCtx); err != nil {
			logger.Error("telemetry shutdown", "error", err)
		}
	}()
	if cfg.OTLPEndpoint == "" {
		logger.Info("OTLP metric shipping disabled (OTEL_EXPORTER_OTLP_ENDPOINT unset)")
	} else {
		logger.Info("OTLP metric shipping enabled", "endpoint", cfg.OTLPEndpoint)
	}

	var anthropicClient anthropic.Client
	switch cfg.LLMProvider {
	case config.ProviderBedrock:
		opts := []func(*awsconfig.LoadOptions) error{}
		if cfg.BedrockRegion != "" {
			opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))
		}
		anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))
		logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)
	default:
		anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))
		logger.Info("using Anthropic API", "model", cfg.ClaudeModel)
	}

	eval := evaluator.New(anthropicClient, cfg.ClaudeModel)

	teleportClient, err := watcher.NewClient(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile)
	if err != nil {
		logger.Error("failed to connect to teleport", "error", err)
		os.Exit(1)
	}
	defer teleportClient.Close()

	reputationMgr := reputation.New(teleportClient, cfg.Reputation)

	w := watcher.New(ctx, teleportClient, logger)

	logger.Info("televibe started", "teleport_addr", cfg.TeleportAddr, "model", cfg.ClaudeModel)

	for {
		select {
		case req, ok := <-w.Requests():
			if !ok {
				return
			}
			go evaluate(ctx, eval, reputationMgr, metrics.Instruments, req, logger)
		case <-ctx.Done():
			return
		}
	}
}

func evaluate(
	ctx context.Context,
	eval *evaluator.Evaluator,
	rep *reputation.Manager,
	instr telemetry.Instruments,
	req evaluator.Request,
	logger *slog.Logger,
) {
	a, err := eval.Evaluate(ctx, req)
	if err != nil {
		logger.Error("evaluation failed", "request_id", req.ID, "user", req.User, "error", err)
		return
	}

	instr.RiskScore.Record(ctx, int64(a.RiskScore), metric.WithAttributes(
		attribute.String("risk_level", string(a.RiskLevel)),
		attribute.Bool("has_ticket", a.HasJIRATicket),
	))

	update, repErr := rep.Apply(ctx, req.User, a)
	if repErr != nil {
		logger.Error("reputation update failed",
			"request_id", req.ID,
			"user", req.User,
			"error", repErr,
		)
	} else {
		instr.ReputationScore.Record(ctx, int64(update.New), metric.WithAttributes(
			attribute.String("user", update.User),
		))
		instr.Adjustments.Add(ctx, 1, metric.WithAttributes(
			attribute.String("risk_level", string(a.RiskLevel)),
			attribute.String("direction", deltaDirection(update.Delta)),
		))
	}

	attrs := []any{
		"request_id", req.ID,
		"user", req.User,
		"roles", req.Roles,
		"risk_score", a.RiskScore,
		"risk_level", a.RiskLevel,
		"has_jira_ticket", a.HasJIRATicket,
		"jira_ticket", a.JIRATicket,
		"description_quality", a.DescriptionQuality,
		"reasoning", a.Reasoning,
	}
	if repErr == nil {
		attrs = append(attrs,
			"reputation_previous", update.Previous,
			"reputation_delta", update.Delta,
			"reputation_new", update.New,
		)
	}
	logger.Info("access request evaluated", attrs...)
	_ = strconv.Itoa // keep import stable if we later decide to format scores
}

func deltaDirection(d int) string {
	switch {
	case d > 0:
		return "positive"
	case d < 0:
		return "negative"
	default:
		return "zero"
	}
}


The file /Users/steve.briggs/projects/operations/services/televibe/main.go has been updated successfully.

package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"strconv"
	"syscall"
	"time"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/immuta/operations/services/televibe/internal/reputation"
	"github.com/immuta/operations/services/televibe/internal/telemetry"
	"github.com/immuta/operations/services/televibe/internal/watcher"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/metric"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("configuration error", "error", err)
		os.Exit(1)
	}

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	metrics, err := telemetry.New(ctx, cfg.OTLPEndpoint)
	if err != nil {
		logger.Error("telemetry init failed", "error", err)
		os.Exit(1)
	}
	defer func() {
		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer shutdownCancel()
		if err := metrics.Shutdown(shutdownCtx); err != nil {
			logger.Error("telemetry shutdown", "error", err)
		}
	}()
	if cfg.OTLPEndpoint == "" {
		logger.Info("OTLP metric shipping disabled (OTEL_EXPORTER_OTLP_ENDPOINT unset)")
	} else {
		logger.Info("OTLP metric shipping enabled", "endpoint", cfg.OTLPEndpoint)
	}

	var anthropicClient anthropic.Client
	switch cfg.LLMProvider {
	case config.ProviderBedrock:
		opts := []func(*awsconfig.LoadOptions) error{}
		if cfg.BedrockRegion != "" {
			opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))
		}
		anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))
		logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)
	default:
		anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))
		logger.Info("using Anthropic API", "model", cfg.ClaudeModel)
	}

	eval := evaluator.New(anthropicClient, cfg.ClaudeModel)

	teleportClient, err := watcher.NewClient(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile)
	if err != nil {
		logger.Error("failed to connect to teleport", "error", err)
		os.Exit(1)
	}
	defer teleportClient.Close()

	reputationMgr := reputation.New(teleportClient, cfg.Reputation)

	w := watcher.New(ctx, teleportClient, logger)

	logger.Info("televibe started", "teleport_addr", cfg.TeleportAddr, "model", cfg.ClaudeModel)

	for {
		select {
		case req, ok := <-w.Requests():
			if !ok {
				return
			}
			go evaluate(ctx, eval, reputationMgr, metrics.Instruments, req, logger)
		case <-ctx.Done():
			return
		}
	}
}

func evaluate(
	ctx context.Context,
	eval *evaluator.Evaluator,
	rep *reputation.Manager,
	instr telemetry.Instruments,
	req evaluator.Request,
	logger *slog.Logger,
) {
	a, err := eval.Evaluate(ctx, req)
	if err != nil {
		logger.Error("evaluation failed", "request_id", req.ID, "user", req.User, "error", err)
		return
	}

	instr.RiskScore.Record(ctx, int64(a.RiskScore), metric.WithAttributes(
		attribute.String("risk_level", string(a.RiskLevel)),
		attribute.Bool("has_ticket", a.HasJIRATicket),
	))

	update, repErr := rep.Apply(ctx, req.User, a)
	if repErr != nil {
		logger.Error("reputation update failed",
			"request_id", req.ID,
			"user", req.User,
			"error", repErr,
		)
	} else {
		instr.ReputationScore.Record(ctx, int64(update.New), metric.WithAttributes(
			attribute.String("user", update.User),
		))
		instr.Adjustments.Add(ctx, 1, metric.WithAttributes(
			attribute.String("risk_level", string(a.RiskLevel)),
			attribute.String("direction", deltaDirection(update.Delta)),
		))
	}

	attrs := []any{
		"request_id", req.ID,
		"user", req.User,
		"roles", req.Roles,
		"risk_score", a.RiskScore,
		"risk_level", a.RiskLevel,
		"has_jira_ticket", a.HasJIRATicket,
		"jira_ticket", a.JIRATicket,
		"description_quality", a.DescriptionQuality,
		"reasoning", a.Reasoning,
	}
	if repErr == nil {
		attrs = append(attrs,
			"reputation_previous", update.Previous,
			"reputation_delta", update.Delta,
			"reputation_new", update.New,
		)
	}
	logger.Info("access request evaluated", attrs...)
	_ = strconv.Itoa // keep import stable if we later decide to format scores
}

func deltaDirection(d int) string {
	switch {
	case d > 0:
		return "positive"
	case d < 0:
		return "negative"
	default:
		return "zero"
	}
}

+  ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)

+  metrics, err := telemetry.New(ctx, cfg.OTLPEndpoint)

+    logger.Error("telemetry init failed", "error", err)

+    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)

+    if err := metrics.Shutdown(shutdownCtx); err != nil {

+      logger.Error("telemetry shutdown", "error", err)

+    logger.Info("OTLP metric shipping disabled (OTEL_EXPORTER_OTLP_ENDPOINT unset)")

+    logger.Info("OTLP metric shipping enabled", "endpoint", cfg.OTLPEndpoint)

-  ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)

-  w, err := watcher.New(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile, logger)

+  teleportClient, err := watcher.NewClient(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile)

+  reputationMgr := reputation.New(teleportClient, cfg.Reputation)

+  w := watcher.New(ctx, teleportClient, logger)

-      go evaluate(ctx, eval, req, logger)

+      go evaluate(ctx, eval, reputationMgr, metrics.Instruments, req, logger)

-func evaluate(ctx context.Context, eval *evaluator.Evaluator, req evaluator.Request, logger *slog.Logger) {

-  logger.Info("access request evaluated",

+  instr.RiskScore.Record(ctx, int64(a.RiskScore), metric.WithAttributes(

+    attribute.String("risk_level", string(a.RiskLevel)),

+    attribute.Bool("has_ticket", a.HasJIRATicket),

+  update, repErr := rep.Apply(ctx, req.User, a)

+    logger.Error("reputation update failed",

+    instr.ReputationScore.Record(ctx, int64(update.New), metric.WithAttributes(

+      attribute.String("user", update.User),

+    instr.Adjustments.Add(ctx, 1, metric.WithAttributes(

+      attribute.String("direction", deltaDirection(update.Delta)),

+      "reputation_previous", update.Previous,

+      "reputation_delta", update.Delta,

+  logger.Info("access request evaluated", attrs...)

+  _ = strconv.Itoa // keep import stable if we later decide to format scores

package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"syscall"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/immuta/operations/services/televibe/internal/watcher"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("configuration error", "error", err)
		os.Exit(1)
	}

	var anthropicClient anthropic.Client
	switch cfg.LLMProvider {
	case config.ProviderBedrock:
		opts := []func(*awsconfig.LoadOptions) error{}
		if cfg.BedrockRegion != "" {
			opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))
		}
		anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))
		logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)
	default:
		anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))
		logger.Info("using Anthropic API", "model", cfg.ClaudeModel)
	}

	eval := evaluator.New(anthropicClient, cfg.ClaudeModel)

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	w, err := watcher.New(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile, logger)
	if err != nil {
		logger.Error("failed to connect to teleport", "error", err)
		os.Exit(1)
	}
	defer w.Close()

	logger.Info("televibe started", "teleport_addr", cfg.TeleportAddr, "model", cfg.ClaudeModel)

	for {
		select {
		case req, ok := <-w.Requests():
			if !ok {
				return
			}
			go evaluate(ctx, eval, req, logger)
		case <-ctx.Done():
			return
		}
	}
}

func evaluate(ctx context.Context, eval *evaluator.Evaluator, req evaluator.Request, logger *slog.Logger) {
	a, err := eval.Evaluate(ctx, req)
	if err != nil {
		logger.Error("evaluation failed", "request_id", req.ID, "user", req.User, "error", err)
		return
	}

	logger.Info("access request evaluated",
		"request_id", req.ID,
		"user", req.User,
		"roles", req.Roles,
		"risk_score", a.RiskScore,
		"risk_level", a.RiskLevel,
		"has_jira_ticket", a.HasJIRATicket,
		"jira_ticket", a.JIRATicket,
		"description_quality", a.DescriptionQuality,
		"reasoning", a.Reasoning,
	)
}


Edit

	"os"
	"os/signal"
	"strconv"
	"syscall"
	"time"


	logger.Info("access request evaluated", attrs...)
	_ = strconv.Itoa // keep import stable if we later decide to format scores
}

	logger.Info("access request evaluated", attrs...)
}


package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"strconv"
	"syscall"
	"time"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/immuta/operations/services/televibe/internal/reputation"
	"github.com/immuta/operations/services/televibe/internal/telemetry"
	"github.com/immuta/operations/services/televibe/internal/watcher"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/metric"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("configuration error", "error", err)
		os.Exit(1)
	}

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	metrics, err := telemetry.New(ctx, cfg.OTLPEndpoint)
	if err != nil {
		logger.Error("telemetry init failed", "error", err)
		os.Exit(1)
	}
	defer func() {
		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer shutdownCancel()
		if err := metrics.Shutdown(shutdownCtx); err != nil {
			logger.Error("telemetry shutdown", "error", err)
		}
	}()
	if cfg.OTLPEndpoint == "" {
		logger.Info("OTLP metric shipping disabled (OTEL_EXPORTER_OTLP_ENDPOINT unset)")
	} else {
		logger.Info("OTLP metric shipping enabled", "endpoint", cfg.OTLPEndpoint)
	}

	var anthropicClient anthropic.Client
	switch cfg.LLMProvider {
	case config.ProviderBedrock:
		opts := []func(*awsconfig.LoadOptions) error{}
		if cfg.BedrockRegion != "" {
			opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))
		}
		anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))
		logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)
	default:
		anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))
		logger.Info("using Anthropic API", "model", cfg.ClaudeModel)
	}

	eval := evaluator.New(anthropicClient, cfg.ClaudeModel)

	teleportClient, err := watcher.NewClient(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile)
	if err != nil {
		logger.Error("failed to connect to teleport", "error", err)
		os.Exit(1)
	}
	defer teleportClient.Close()

	reputationMgr := reputation.New(teleportClient, cfg.Reputation)

	w := watcher.New(ctx, teleportClient, logger)

	logger.Info("televibe started", "teleport_addr", cfg.TeleportAddr, "model", cfg.ClaudeModel)

	for {
		select {
		case req, ok := <-w.Requests():
			if !ok {
				return
			}
			go evaluate(ctx, eval, reputationMgr, metrics.Instruments, req, logger)
		case <-ctx.Done():
			return
		}
	}
}

func evaluate(
	ctx context.Context,
	eval *evaluator.Evaluator,
	rep *reputation.Manager,
	instr telemetry.Instruments,
	req evaluator.Request,
	logger *slog.Logger,
) {
	a, err := eval.Evaluate(ctx, req)
	if err != nil {
		logger.Error("evaluation failed", "request_id", req.ID, "user", req.User, "error", err)
		return
	}

	instr.RiskScore.Record(ctx, int64(a.RiskScore), metric.WithAttributes(
		attribute.String("risk_level", string(a.RiskLevel)),
		attribute.Bool("has_ticket", a.HasJIRATicket),
	))

	update, repErr := rep.Apply(ctx, req.User, a)
	if repErr != nil {
		logger.Error("reputation update failed",
			"request_id", req.ID,
			"user", req.User,
			"error", repErr,
		)
	} else {
		instr.ReputationScore.Record(ctx, int64(update.New), metric.WithAttributes(
			attribute.String("user", update.User),
		))
		instr.Adjustments.Add(ctx, 1, metric.WithAttributes(
			attribute.String("risk_level", string(a.RiskLevel)),
			attribute.String("direction", deltaDirection(update.Delta)),
		))
	}

	attrs := []any{
		"request_id", req.ID,
		"user", req.User,
		"roles", req.Roles,
		"risk_score", a.RiskScore,
		"risk_level", a.RiskLevel,
		"has_jira_ticket", a.HasJIRATicket,
		"jira_ticket", a.JIRATicket,
		"description_quality", a.DescriptionQuality,
		"reasoning", a.Reasoning,
	}
	if repErr == nil {
		attrs = append(attrs,
			"reputation_previous", update.Previous,
			"reputation_delta", update.Delta,
			"reputation_new", update.New,
		)
	}
	logger.Info("access request evaluated", attrs...)
	_ = strconv.Itoa // keep import stable if we later decide to format scores
}

func deltaDirection(d int) string {
	switch {
	case d > 0:
		return "positive"
	case d < 0:
		return "negative"
	default:
		return "zero"
	}
}

"log/slog"
"os"
"os/signal"
-  "strconv"
"syscall"
"time"


	logger.Info("access request evaluated", attrs...)
}

package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/bedrock"
	"github.com/anthropics/anthropic-sdk-go/option"
	awsconfig "github.com/aws/aws-sdk-go-v2/config"
	"github.com/immuta/operations/services/televibe/internal/config"
	"github.com/immuta/operations/services/televibe/internal/evaluator"
	"github.com/immuta/operations/services/televibe/internal/reputation"
	"github.com/immuta/operations/services/televibe/internal/telemetry"
	"github.com/immuta/operations/services/televibe/internal/watcher"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/metric"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	cfg, err := config.Load()
	if err != nil {
		logger.Error("configuration error", "error", err)
		os.Exit(1)
	}

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	metrics, err := telemetry.New(ctx, cfg.OTLPEndpoint)
	if err != nil {
		logger.Error("telemetry init failed", "error", err)
		os.Exit(1)
	}
	defer func() {
		shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer shutdownCancel()
		if err := metrics.Shutdown(shutdownCtx); err != nil {
			logger.Error("telemetry shutdown", "error", err)
		}
	}()
	if cfg.OTLPEndpoint == "" {
		logger.Info("OTLP metric shipping disabled (OTEL_EXPORTER_OTLP_ENDPOINT unset)")
	} else {
		logger.Info("OTLP metric shipping enabled", "endpoint", cfg.OTLPEndpoint)
	}

	var anthropicClient anthropic.Client
	switch cfg.LLMProvider {
	case config.ProviderBedrock:
		opts := []func(*awsconfig.LoadOptions) error{}
		if cfg.BedrockRegion != "" {
			opts = append(opts, awsconfig.WithRegion(cfg.BedrockRegion))
		}
		anthropicClient = anthropic.NewClient(bedrock.WithLoadDefaultConfig(context.Background(), opts...))
		logger.Info("using AWS Bedrock", "region", cfg.BedrockRegion, "model", cfg.ClaudeModel)
	default:
		anthropicClient = anthropic.NewClient(option.WithAPIKey(cfg.AnthropicAPIKey))
		logger.Info("using Anthropic API", "model", cfg.ClaudeModel)
	}

	eval := evaluator.New(anthropicClient, cfg.ClaudeModel)

	teleportClient, err := watcher.NewClient(ctx, cfg.TeleportAddr, cfg.TeleportIdentityFile)
	if err != nil {
		logger.Error("failed to connect to teleport", "error", err)
		os.Exit(1)
	}
	defer teleportClient.Close()

	reputationMgr := reputation.New(teleportClient, cfg.Reputation)

	w := watcher.New(ctx, teleportClient, logger)

	logger.Info("televibe started", "teleport_addr", cfg.TeleportAddr, "model", cfg.ClaudeModel)

	for {
		select {
		case req, ok := <-w.Requests():
			if !ok {
				return
			}
			go evaluate(ctx, eval, reputationMgr, metrics.Instruments, req, logger)
		case <-ctx.Done():
			return
		}
	}
}

func evaluate(
	ctx context.Context,
	eval *evaluator.Evaluator,
	rep *reputation.Manager,
	instr telemetry.Instruments,
	req evaluator.Request,
	logger *slog.Logger,
) {
	a, err := eval.Evaluate(ctx, req)
	if err != nil {
		logger.Error("evaluation failed", "request_id", req.ID, "user", req.User, "error", err)
		return
	}

	instr.RiskScore.Record(ctx, int64(a.RiskScore), metric.WithAttributes(
		attribute.String("risk_level", string(a.RiskLevel)),
		attribute.Bool("has_ticket", a.HasJIRATicket),
	))

	update, repErr := rep.Apply(ctx, req.User, a)
	if repErr != nil {
		logger.Error("reputation update failed",
			"request_id", req.ID,
			"user", req.User,
			"error", repErr,
		)
	} else {
		instr.ReputationScore.Record(ctx, int64(update.New), metric.WithAttributes(
			attribute.String("user", update.User),
		))
		instr.Adjustments.Add(ctx, 1, metric.WithAttributes(
			attribute.String("risk_level", string(a.RiskLevel)),
			attribute.String("direction", deltaDirection(update.Delta)),
		))
	}

	attrs := []any{
		"request_id", req.ID,
		"user", req.User,
		"roles", req.Roles,
		"risk_score", a.RiskScore,
		"risk_level", a.RiskLevel,
		"has_jira_ticket", a.HasJIRATicket,
		"jira_ticket", a.JIRATicket,
		"description_quality", a.DescriptionQuality,
		"reasoning", a.Reasoning,
	}
	if repErr == nil {
		attrs = append(attrs,
			"reputation_previous", update.Previous,
			"reputation_delta", update.Delta,
			"reputation_new", update.New,
		)
	}
	logger.Info("access request evaluated", attrs...)
	_ = strconv.Itoa // keep import stable if we later decide to format scores
}

func deltaDirection(d int) string {
	switch {
	case d > 0:
		return "positive"
	case d < 0:
		return "negative"
	default:
		return "zero"
	}
}

-  _ = strconv.Itoa // keep import stable if we later decide to format scores


Now pull the otlpmetric dep, build, and test.


Bash

mise exec -- go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc@v1.43.0 2>&1 | tail -20

Add otlpmetricgrpc dependency


go: module go.opentelemetry.io/otel@v1.43.0 found, but does not contain package go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc


mise exec -- go list -m -versions go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc 2>&1 | tail -3

Check otlpmetricgrpc versions


go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.21.0 v0.22.0 v0.23.0 v0.24.0 v0.25.0 v0.26.0 v0.27.0 v0.28.0 v0.29.0 v0.30.0 v0.31.0 v0.32.0 v0.32.1 v0.32.3 v0.33.0 v0.34.0 v0.35.0 v0.36.0 v0.37.0 v0.38.0-rc.1 v0.38.0-rc.2 v0.38.0 v0.38.1 v0.39.0-rc.1 v0.39.0 v0.40.0 v0.41.0 v0.42.0-rc.1 v0.42.0 v0.43.0 v0.44.0 v0.45.0 v1.23.0-rc.1 v1.23.0 v1.23.1 v1.24.0 v1.25.0 v1.26.0 v1.27.0 v1.28.0 v1.29.0 v1.30.0 v1.31.0 v1.32.0 v1.33.0 v1.34.0 v1.35.0 v1.36.0 v1.37.0 v1.38.0 v1.39.0 v1.40.0 v1.41.0 v1.42.0 v1.43.0


mise exec -- go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc@v1.43.0 2>&1 | tail -10

Add otlpmetricgrpc dep from televibe dir


go: downloading go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go: added go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0


mise exec -- go build ./... 2>&1 | tail -40

Build televibe


Add reputation & OTLP config fields

Extend internal/config with OTLP endpoint, delta values, clamp range, starting score. Update config tests.

Create internal/telemetry package

OTel meter bootstrap with OTLP gRPC exporter. No-op provider when OTEL_EXPORTER_OTLP_ENDPOINT unset. Expose histogram/gauge/counter instruments used by the evaluate path.

Create internal/reputation package

Pure delta computation from assessment + config, and a Teleport-backed store that reads/writes user traits. Small interface for testability. Per-user mutex to avoid clobber.

Wire into main.go + add otlpmetricgrpc dep

Initialize telemetry + reputation in main, thread through evaluate goroutine. Add go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc to go.mod.

Run go build and go test

go build ./... and go test -race ./... from the televibe directory. Fix anything that breaks.


Build clean. Running the full test suite with race detector.


Updated task #5 status


mise exec -- go test -race ./... 2>&1 | tail -60

Run all tests with race detector


?   	github.com/immuta/operations/services/televibe	[no test files]
ok  	github.com/immuta/operations/services/televibe/internal/config	2.139s
ok  	github.com/immuta/operations/services/televibe/internal/evaluator	1.931s
ok  	github.com/immuta/operations/services/televibe/internal/reputation	1.855s
ok  	github.com/immuta/operations/services/televibe/internal/telemetry	2.571s
?   	github.com/immuta/operations/services/televibe/internal/watcher	[no test files]


mise exec -- go vet ./... 2>&1 | tail -10 && echo "---" && mise exec -- go mod tidy 2>&1 | tail -10

Run go vet and tidy


mise exec -- git diff --stat HEAD -- . 2>&1 | tail -20

Summarize changes


(Bash completed with no output)


git -C /Users/steve.briggs/projects/operations/services/televibe diff --stat HEAD 2>&1 | tail -20 && echo "---untracked---" && git -C /Users/steve.briggs/projects/operations/services/televibe ls-files --others --exclude-standard 2>&1

Show changes summary


---untracked---
Dockerfile
go.mod
go.sum
identity.file
internal/config/config.go
internal/config/config_test.go
internal/evaluator/evaluator.go
internal/evaluator/evaluator_test.go
internal/reputation/reputation.go
internal/reputation/reputation_test.go
internal/telemetry/telemetry.go
internal/telemetry/telemetry_test.go
internal/watcher/watcher.go
logo.png
main.go


All green — build, `go vet`, and `go test -race ./...` all pass.

## What landed

**`internal/config/config.go:17`** — new `Reputation` struct + env vars:
- `REPUTATION_DELTA_LOW` (+1), `REPUTATION_DELTA_MEDIUM` (0), `REPUTATION_DELTA_HIGH` (−2)
- `REPUTATION_MIN` (−20), `REPUTATION_MAX` (+20), `REPUTATION_START` (0)
- `OTEL_EXPORTER_OTLP_ENDPOINT` — empty disables OTLP shipping entirely

**`internal/telemetry/telemetry.go`** — OTel meter bootstrap. Empty endpoint → `metric/noop` provider with a nil-op `Shutdown`. Non-empty → OTLP gRPC (insecure) with a periodic reader and `service.name=televibe` resource. Three instruments:
- `televibe.assessment.risk_score` (Int64Histogram, tags: `risk_level`, `has_ticket`)
- `televibe.reputation.score` (Int64Gauge, tags: `user`)
- `televibe.reputation.adjustments` (Int64Counter, tags: `risk_level`, `direction`)

**`internal/reputation/reputation.go`** — `Manager.Apply` reads `reputation_score`/`reputation_updated_at`/`reputation_last_delta` traits via a small `UserClient` interface (satisfied by `*teleport/api/client.Client`), computes the clamped new score, and writes the traits back. Per-user `sync.Mutex` map serializes concurrent writes for the same user. Pure `Compute(prev, level, cfg)` exposed for testability.

**`internal/watcher/watcher.go`** — refactored: `NewClient` builds the Teleport client; `New` now takes a `*client.Client` so `main` can share it with the reputation manager. Client lifecycle moved to `main`.

**`main.go`** — wires telemetry + reputation into the evaluate goroutine. Records the risk-score histogram on every assessment, then calls `reputation.Apply` and emits the gauge + adjustments counter. Reputation failures are logged but don't drop the evaluation. **No Teleport access-request action taken** — manual-only, as requested.

## Test coverage

- `reputation` has 7 tests incl. a 50-goroutine concurrent `Apply` test that verifies serialization + correct final clamped value.
- `telemetry` verifies the noop path is safe when the endpoint is unset.
- `config` extended for the new env vars, validation errors, and clamp coherence.

## To turn it on

Set `OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317` (or your collector's address) in the televibe environment. Leave it unset to run with logging only. Confirm the identity file's Teleport role grants `user.read` + `user.update` before first use or `Apply` will error.


write a good commit message for this change


```
feat(televibe): add reputation scoring with OTel metrics export

Track a per-user reputation score based on the evaluator's risk level.
Low-risk requests nudge the score up, high-risk pulls it down; the
running score persists as Teleport user traits (reputation_score,
reputation_updated_at, reputation_last_delta) so approvers see it in
the Teleport UI at review time. Deltas, clamp range, and starting
value are tunable via REPUTATION_* env vars.

Risk and reputation metrics ship via OTLP gRPC when
OTEL_EXPORTER_OTLP_ENDPOINT is set, falling back to a noop meter
otherwise. Three instruments are emitted: risk_score histogram
(tagged by risk_level, has_ticket), reputation.score gauge (tagged
by user), and reputation.adjustments counter (tagged by risk_level,
direction).

Televibe still does not act on access requests — the reputation
trait is observational only, so approvers can factor it into manual
review while the scoring model is tuned. Reputation writes require
user.read and user.update on the service identity's Teleport role.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
```

Single-line subject is `feat(televibe): add reputation scoring with OTel metrics export` (62 chars — fits in 72). Matches the `type(scope):` style the rest of the repo uses (`ci(SRE-2757):`, `chore(ITSM-9198):`). Drop the `Co-Authored-By` trailer if you'd rather commit without it. Want me to actually create the commit, or leave that to you?


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<command-name>/exit</command-name>
            <command-message>exit</command-message>
            <command-args></command-args>


<local-command-stdout>See ya!</local-command-stdout>