The file /Users/steve.briggs/projects/operations/apps/televibe/config.go has been updated successfully.

	"televibe/ai"
	"televibe/jira"
	"televibe/reputation"
	"televibe/risk"
	"televibe/telemetry"
)

type Config struct {
	Teleport   TeleportConfig    `toml:"teleport"`
	AI         ai.Config         `toml:"ai"`
	Jira       jira.Config       `toml:"jira"`
	Risk       risk.Config       `toml:"risk"`
	Reputation reputation.Config `toml:"reputation"`
	Telemetry  telemetry.Config  `toml:"telemetry"`
	Log        LogConfig         `toml:"log"`
}

package televibe

import (
	"fmt"
	"os"

	"github.com/pelletier/go-toml/v2"

	"televibe/ai"
	"televibe/jira"
	"televibe/risk"
)

type Config struct {
	Teleport TeleportConfig `toml:"teleport"`
	AI       ai.Config      `toml:"ai"`
	Jira     jira.Config    `toml:"jira"`
	Risk     risk.Config    `toml:"risk"`
	Log      LogConfig      `toml:"log"`
}

type TeleportConfig struct {
	Addr         string `toml:"addr"`
	IdentityFile string `toml:"identity_file"`
}

type LogConfig struct {
	Level string `toml:"level"`
}

func LoadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config: %w", err)
	}
	cfg := &Config{}
	if err := toml.Unmarshal(data, cfg); err != nil {
		return nil, fmt.Errorf("parse config: %w", err)
	}
	cfg.applyDefaults()
	if err := cfg.validate(); err != nil {
		return nil, err
	}
	return cfg, nil
}

func (c *Config) applyDefaults() {
	c.Jira.ApplyDefaults()
	c.Risk.ApplyDefaults()
	if c.Log.Level == "" {
		c.Log.Level = "info"
	}
}

func (c *Config) validate() error {
	if c.Teleport.Addr == "" {
		return fmt.Errorf("teleport.addr is required")
	}
	if c.Teleport.IdentityFile == "" {
		return fmt.Errorf("teleport.identity_file is required")
	}
	if err := c.AI.Validate(); err != nil {
		return fmt.Errorf("ai: %w", err)
	}
	if err := c.Jira.Validate(); err != nil {
		return fmt.Errorf("jira: %w", err)
	}
	return nil
}

-  Teleport TeleportConfig `toml:"teleport"`

-  Jira     jira.Config    `toml:"jira"`

-  Risk     risk.Config    `toml:"risk"`

+  Teleport   TeleportConfig    `toml:"teleport"`

+  AI         ai.Config         `toml:"ai"`

+  Jira       jira.Config       `toml:"jira"`

+  Risk       risk.Config       `toml:"risk"`

+  Reputation reputation.Config `toml:"reputation"`

+  Telemetry  telemetry.Config  `toml:"telemetry"`

+  Log        LogConfig         `toml:"log"`


Edit

func (c *Config) applyDefaults() {
	c.Jira.ApplyDefaults()
	c.Risk.ApplyDefaults()
	if c.Log.Level == "" {
		c.Log.Level = "info"
	}
}

func (c *Config) applyDefaults() {
	c.Jira.ApplyDefaults()
	c.Risk.ApplyDefaults()
	c.Reputation.ApplyDefaults()
	c.Telemetry.ApplyDefaults()
	if c.Log.Level == "" {
		c.Log.Level = "info"
	}
}


func (c *Config) applyDefaults() {
	c.Jira.ApplyDefaults()
	c.Risk.ApplyDefaults()
	c.Reputation.ApplyDefaults()
	c.Telemetry.ApplyDefaults()
	if c.Log.Level == "" {
		c.Log.Level = "info"
	}
}

package televibe

import (
	"fmt"
	"os"

	"github.com/pelletier/go-toml/v2"

	"televibe/ai"
	"televibe/jira"
	"televibe/reputation"
	"televibe/risk"
	"televibe/telemetry"
)

type Config struct {
	Teleport   TeleportConfig    `toml:"teleport"`
	AI         ai.Config         `toml:"ai"`
	Jira       jira.Config       `toml:"jira"`
	Risk       risk.Config       `toml:"risk"`
	Reputation reputation.Config `toml:"reputation"`
	Telemetry  telemetry.Config  `toml:"telemetry"`
	Log        LogConfig         `toml:"log"`
}

type TeleportConfig struct {
	Addr         string `toml:"addr"`
	IdentityFile string `toml:"identity_file"`
}

type LogConfig struct {
	Level string `toml:"level"`
}

func LoadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config: %w", err)
	}
	cfg := &Config{}
	if err := toml.Unmarshal(data, cfg); err != nil {
		return nil, fmt.Errorf("parse config: %w", err)
	}
	cfg.applyDefaults()
	if err := cfg.validate(); err != nil {
		return nil, err
	}
	return cfg, nil
}

func (c *Config) applyDefaults() {
	c.Jira.ApplyDefaults()
	c.Risk.ApplyDefaults()
	if c.Log.Level == "" {
		c.Log.Level = "info"
	}
}

func (c *Config) validate() error {
	if c.Teleport.Addr == "" {
		return fmt.Errorf("teleport.addr is required")
	}
	if c.Teleport.IdentityFile == "" {
		return fmt.Errorf("teleport.identity_file is required")
	}
	if err := c.AI.Validate(); err != nil {
		return fmt.Errorf("ai: %w", err)
	}
	if err := c.Jira.Validate(); err != nil {
		return fmt.Errorf("jira: %w", err)
	}
	return nil
}

func (c *Config) applyDefaults() {
c.Jira.ApplyDefaults()
c.Risk.ApplyDefaults()
+  c.Reputation.ApplyDefaults()
+  c.Telemetry.ApplyDefaults()
if c.Log.Level == "" {
c.Log.Level = "info"
}


Lets add user reputation scoring to this, the user should start with a neutral reputation and gradually get worse when they submit a bad teleport request (i.e. ndescriptive request or missing a CUSTX…


	if err := c.Jira.Validate(); err != nil {
		return fmt.Errorf("jira: %w", err)
	}
	return nil
}

	if err := c.Jira.Validate(); err != nil {
		return fmt.Errorf("jira: %w", err)
	}
	if err := c.Reputation.Validate(); err != nil {
		return fmt.Errorf("reputation: %w", err)
	}
	return nil
}


	if err := c.Jira.Validate(); err != nil {
		return fmt.Errorf("jira: %w", err)
	}
	if err := c.Reputation.Validate(); err != nil {
		return fmt.Errorf("reputation: %w", err)
	}
	return nil
}

package televibe

import (
	"fmt"
	"os"

	"github.com/pelletier/go-toml/v2"

	"televibe/ai"
	"televibe/jira"
	"televibe/reputation"
	"televibe/risk"
	"televibe/telemetry"
)

type Config struct {
	Teleport   TeleportConfig    `toml:"teleport"`
	AI         ai.Config         `toml:"ai"`
	Jira       jira.Config       `toml:"jira"`
	Risk       risk.Config       `toml:"risk"`
	Reputation reputation.Config `toml:"reputation"`
	Telemetry  telemetry.Config  `toml:"telemetry"`
	Log        LogConfig         `toml:"log"`
}

type TeleportConfig struct {
	Addr         string `toml:"addr"`
	IdentityFile string `toml:"identity_file"`
}

type LogConfig struct {
	Level string `toml:"level"`
}

func LoadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config: %w", err)
	}
	cfg := &Config{}
	if err := toml.Unmarshal(data, cfg); err != nil {
		return nil, fmt.Errorf("parse config: %w", err)
	}
	cfg.applyDefaults()
	if err := cfg.validate(); err != nil {
		return nil, err
	}
	return cfg, nil
}

func (c *Config) applyDefaults() {
	c.Jira.ApplyDefaults()
	c.Risk.ApplyDefaults()
	c.Reputation.ApplyDefaults()
	c.Telemetry.ApplyDefaults()
	if c.Log.Level == "" {
		c.Log.Level = "info"
	}
}

func (c *Config) validate() error {
	if c.Teleport.Addr == "" {
		return fmt.Errorf("teleport.addr is required")
	}
	if c.Teleport.IdentityFile == "" {
		return fmt.Errorf("teleport.identity_file is required")
	}
	if err := c.AI.Validate(); err != nil {
		return fmt.Errorf("ai: %w", err)
	}
	if err := c.Jira.Validate(); err != nil {
		return fmt.Errorf("jira: %w", err)
	}
	return nil
}

+  if err := c.Reputation.Validate(); err != nil {

+    return fmt.Errorf("reputation: %w", err)


Now `app.go` — add store + meter fields, init them, call `Assess` in `handleRequest`, expose `Close`.


	"televibe/ai"
	"televibe/jira"
	"televibe/risk"
)

type App struct {
	cfg    *Config
	tele   *client.Client
	ai     ai.Provider
	jira   *jira.Client
	scorer *risk.Scorer

	pending   map[string]string
	pendingMu sync.Mutex
}

	"televibe/ai"
	"televibe/jira"
	"televibe/reputation"
	"televibe/risk"
	"televibe/telemetry"
)

type App struct {
	cfg    *Config
	tele   *client.Client
	ai     ai.Provider
	jira   *jira.Client
	scorer *risk.Scorer
	rep    *reputation.Store
	meter  *telemetry.Meter

	pending   map[string]string
	pendingMu sync.Mutex
}


The file /Users/steve.briggs/projects/operations/apps/televibe/app.go has been updated successfully.

	"televibe/ai"
	"televibe/jira"
	"televibe/reputation"
	"televibe/risk"
	"televibe/telemetry"
)

type App struct {
	cfg    *Config
	tele   *client.Client
	ai     ai.Provider
	jira   *jira.Client
	scorer *risk.Scorer
	rep    *reputation.Store
	meter  *telemetry.Meter

	pending   map[string]string
	pendingMu sync.Mutex
}

package televibe

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

	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
	"golang.org/x/sync/errgroup"

	"televibe/ai"
	"televibe/jira"
	"televibe/risk"
)

type App struct {
	cfg    *Config
	tele   *client.Client
	ai     ai.Provider
	jira   *jira.Client
	scorer *risk.Scorer

	pending   map[string]string
	pendingMu sync.Mutex
}

func NewApp(ctx context.Context, cfg *Config) (*App, error) {
	tele, err := client.New(ctx, client.Config{
		Addrs: []string{cfg.Teleport.Addr},
		Credentials: []client.Credentials{
			client.LoadIdentityFile(cfg.Teleport.IdentityFile),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("teleport client: %w", err)
	}

	prov, err := ai.New(cfg.AI)
	if err != nil {
		return nil, fmt.Errorf("ai provider: %w", err)
	}

	jc, err := jira.New(cfg.Jira)
	if err != nil {
		return nil, fmt.Errorf("jira client: %w", err)
	}

	scorer, err := risk.NewScorer(cfg.Risk)
	if err != nil {
		return nil, fmt.Errorf("scorer: %w", err)
	}

	return &App{
		cfg:     cfg,
		tele:    tele,
		ai:      prov,
		jira:    jc,
		scorer:  scorer,
		pending: map[string]string{},
	}, nil
}

func (a *App) Run(ctx context.Context) error {
	slog.Info("televibe starting",
		"provider", a.cfg.AI.Provider,
		"threshold", a.cfg.Risk.TicketThreshold,
		"itsm_project", a.cfg.Jira.Project)

	if err := a.rehydrate(ctx); err != nil {
		return fmt.Errorf("rehydrate open ITSM tickets: %w", err)
	}

	g, gctx := errgroup.WithContext(ctx)
	g.Go(func() error { return a.watchRequests(gctx) })
	g.Go(func() error { return a.pollITSM(gctx) })
	return g.Wait()
}

func (a *App) rehydrate(ctx context.Context) error {
	open, err := a.jira.ListOpenTeleportTickets(ctx)
	if err != nil {
		return err
	}
	a.pendingMu.Lock()
	for reqID, ticket := range open {
		a.pending[reqID] = ticket
	}
	a.pendingMu.Unlock()
	slog.Info("rehydrated pending tickets", "count", len(open))
	return nil
}

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

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-watcher.Done():
			return watcher.Error()
		case ev := <-watcher.Events():
			if ev.Type != types.OpPut {
				continue
			}
			req, ok := ev.Resource.(types.AccessRequest)
			if !ok {
				continue
			}
			if req.GetState() != types.RequestState_PENDING {
				continue
			}
			a.pendingMu.Lock()
			_, held := a.pending[req.GetName()]
			a.pendingMu.Unlock()
			if held {
				continue
			}
			go a.handleRequest(ctx, req)
		}
	}
}

func (a *App) handleRequest(ctx context.Context, req types.AccessRequest) {
	id := req.GetName()
	log := slog.With("request_id", id, "user", req.GetUser())

	log.Info("scoring access request")
	result, err := a.scorer.Score(ctx, a.ai, req)
	if err != nil {
		log.Error("scoring failed", "err", err)
		return
	}
	log.Info("risk scored",
		"score", result.Score,
		"tier", result.Tier.Name,
		"slang", result.Tier.Slang,
		"components", result.Components)

	if result.Score < a.cfg.Risk.TicketThreshold {
		reason := fmt.Sprintf("televibe: %s (score %d, %q) — auto-approved",
			result.Tier.Name, result.Score, result.Tier.Slang)
		if err := a.submitReview(ctx, id, types.RequestState_APPROVED, reason); err != nil {
			log.Error("auto-approve failed", "err", err)
		}
		return
	}

	ticket, err := a.jira.CreateITSMTicket(ctx, jira.TicketInput{
		RequestID:   id,
		User:        req.GetUser(),
		Roles:       req.GetRoles(),
		Reason:      req.GetRequestReason(),
		Score:       result.Score,
		Tier:        result.Tier.Name,
		Slang:       result.Tier.Slang,
		Components:  result.Components,
		AIReasoning: result.Reasoning,
	})
	if err != nil {
		log.Error("create ITSM ticket failed", "err", err)
		return
	}

	a.pendingMu.Lock()
	a.pending[id] = ticket
	a.pendingMu.Unlock()
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name, "slang", result.Tier.Slang)
}

func (a *App) pollITSM(ctx context.Context) error {
	t := time.NewTicker(a.cfg.Jira.PollInterval)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-t.C:
			a.reconcileITSM(ctx)
		}
	}
}

func (a *App) reconcileITSM(ctx context.Context) {
	a.pendingMu.Lock()
	snap := make(map[string]string, len(a.pending))
	for k, v := range a.pending {
		snap[k] = v
	}
	a.pendingMu.Unlock()

	for requestID, ticket := range snap {
		status, err := a.jira.TicketStatus(ctx, ticket)
		if err != nil {
			slog.Warn("ITSM status check failed", "ticket", ticket, "err", err)
			continue
		}
		var next types.RequestState
		switch status {
		case jira.StatusApproved:
			next = types.RequestState_APPROVED
		case jira.StatusRejected:
			next = types.RequestState_DENIED
		default:
			continue
		}
		reason := fmt.Sprintf("televibe: ITSM %s resolved (%s)", ticket, status)
		if err := a.submitReview(ctx, requestID, next, reason); err != nil {
			slog.Error("submit review failed", "request_id", requestID, "err", err)
			continue
		}
		a.pendingMu.Lock()
		delete(a.pending, requestID)
		a.pendingMu.Unlock()
	}
}

func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
	_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
		RequestID: requestID,
		Review: types.AccessReview{
			Author:        "televibe",
			ProposedState: state,
			Reason:        reason,
		},
	})
	return err
}

"televibe/ai"
"televibe/jira"
+  "televibe/reputation"
"televibe/risk"
+  "televibe/telemetry"
)
type App struct {

ai     ai.Provider
jira   *jira.Client
scorer *risk.Scorer
+  rep    *reputation.Store
+  meter  *telemetry.Meter
pending   map[string]string
pendingMu sync.Mutex


	scorer, err := risk.NewScorer(cfg.Risk)
	if err != nil {
		return nil, fmt.Errorf("scorer: %w", err)
	}

	return &App{
		cfg:     cfg,
		tele:    tele,
		ai:      prov,
		jira:    jc,
		scorer:  scorer,
		pending: map[string]string{},
	}, nil
}

	scorer, err := risk.NewScorer(cfg.Risk)
	if err != nil {
		return nil, fmt.Errorf("scorer: %w", err)
	}

	store, err := reputation.NewStore(cfg.Reputation)
	if err != nil {
		return nil, fmt.Errorf("reputation store: %w", err)
	}

	var meter *telemetry.Meter
	if cfg.Telemetry.Enabled {
		meter, err = telemetry.New(ctx, cfg.Telemetry, store)
		if err != nil {
			return nil, fmt.Errorf("telemetry: %w", err)
		}
	}

	return &App{
		cfg:     cfg,
		tele:    tele,
		ai:      prov,
		jira:    jc,
		scorer:  scorer,
		rep:     store,
		meter:   meter,
		pending: map[string]string{},
	}, nil
}

// Close flushes outstanding telemetry. Safe to call even if telemetry
// is disabled.
func (a *App) Close(ctx context.Context) error {
	return a.meter.Shutdown(ctx)
}


	scorer, err := risk.NewScorer(cfg.Risk)
	if err != nil {
		return nil, fmt.Errorf("scorer: %w", err)
	}

	store, err := reputation.NewStore(cfg.Reputation)
	if err != nil {
		return nil, fmt.Errorf("reputation store: %w", err)
	}

	var meter *telemetry.Meter
	if cfg.Telemetry.Enabled {
		meter, err = telemetry.New(ctx, cfg.Telemetry, store)
		if err != nil {
			return nil, fmt.Errorf("telemetry: %w", err)
		}
	}

	return &App{
		cfg:     cfg,
		tele:    tele,
		ai:      prov,
		jira:    jc,
		scorer:  scorer,
		rep:     store,
		meter:   meter,
		pending: map[string]string{},
	}, nil
}

// Close flushes outstanding telemetry. Safe to call even if telemetry
// is disabled.
func (a *App) Close(ctx context.Context) error {
	return a.meter.Shutdown(ctx)
}

package televibe

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

	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
	"golang.org/x/sync/errgroup"

	"televibe/ai"
	"televibe/jira"
	"televibe/reputation"
	"televibe/risk"
	"televibe/telemetry"
)

type App struct {
	cfg    *Config
	tele   *client.Client
	ai     ai.Provider
	jira   *jira.Client
	scorer *risk.Scorer
	rep    *reputation.Store
	meter  *telemetry.Meter

	pending   map[string]string
	pendingMu sync.Mutex
}

func NewApp(ctx context.Context, cfg *Config) (*App, error) {
	tele, err := client.New(ctx, client.Config{
		Addrs: []string{cfg.Teleport.Addr},
		Credentials: []client.Credentials{
			client.LoadIdentityFile(cfg.Teleport.IdentityFile),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("teleport client: %w", err)
	}

	prov, err := ai.New(cfg.AI)
	if err != nil {
		return nil, fmt.Errorf("ai provider: %w", err)
	}

	jc, err := jira.New(cfg.Jira)
	if err != nil {
		return nil, fmt.Errorf("jira client: %w", err)
	}

	scorer, err := risk.NewScorer(cfg.Risk)
	if err != nil {
		return nil, fmt.Errorf("scorer: %w", err)
	}

	return &App{
		cfg:     cfg,
		tele:    tele,
		ai:      prov,
		jira:    jc,
		scorer:  scorer,
		pending: map[string]string{},
	}, nil
}

func (a *App) Run(ctx context.Context) error {
	slog.Info("televibe starting",
		"provider", a.cfg.AI.Provider,
		"threshold", a.cfg.Risk.TicketThreshold,
		"itsm_project", a.cfg.Jira.Project)

	if err := a.rehydrate(ctx); err != nil {
		return fmt.Errorf("rehydrate open ITSM tickets: %w", err)
	}

	g, gctx := errgroup.WithContext(ctx)
	g.Go(func() error { return a.watchRequests(gctx) })
	g.Go(func() error { return a.pollITSM(gctx) })
	return g.Wait()
}

func (a *App) rehydrate(ctx context.Context) error {
	open, err := a.jira.ListOpenTeleportTickets(ctx)
	if err != nil {
		return err
	}
	a.pendingMu.Lock()
	for reqID, ticket := range open {
		a.pending[reqID] = ticket
	}
	a.pendingMu.Unlock()
	slog.Info("rehydrated pending tickets", "count", len(open))
	return nil
}

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

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-watcher.Done():
			return watcher.Error()
		case ev := <-watcher.Events():
			if ev.Type != types.OpPut {
				continue
			}
			req, ok := ev.Resource.(types.AccessRequest)
			if !ok {
				continue
			}
			if req.GetState() != types.RequestState_PENDING {
				continue
			}
			a.pendingMu.Lock()
			_, held := a.pending[req.GetName()]
			a.pendingMu.Unlock()
			if held {
				continue
			}
			go a.handleRequest(ctx, req)
		}
	}
}

func (a *App) handleRequest(ctx context.Context, req types.AccessRequest) {
	id := req.GetName()
	log := slog.With("request_id", id, "user", req.GetUser())

	log.Info("scoring access request")
	result, err := a.scorer.Score(ctx, a.ai, req)
	if err != nil {
		log.Error("scoring failed", "err", err)
		return
	}
	log.Info("risk scored",
		"score", result.Score,
		"tier", result.Tier.Name,
		"slang", result.Tier.Slang,
		"components", result.Components)

	if result.Score < a.cfg.Risk.TicketThreshold {
		reason := fmt.Sprintf("televibe: %s (score %d, %q) — auto-approved",
			result.Tier.Name, result.Score, result.Tier.Slang)
		if err := a.submitReview(ctx, id, types.RequestState_APPROVED, reason); err != nil {
			log.Error("auto-approve failed", "err", err)
		}
		return
	}

	ticket, err := a.jira.CreateITSMTicket(ctx, jira.TicketInput{
		RequestID:   id,
		User:        req.GetUser(),
		Roles:       req.GetRoles(),
		Reason:      req.GetRequestReason(),
		Score:       result.Score,
		Tier:        result.Tier.Name,
		Slang:       result.Tier.Slang,
		Components:  result.Components,
		AIReasoning: result.Reasoning,
	})
	if err != nil {
		log.Error("create ITSM ticket failed", "err", err)
		return
	}

	a.pendingMu.Lock()
	a.pending[id] = ticket
	a.pendingMu.Unlock()
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name, "slang", result.Tier.Slang)
}

func (a *App) pollITSM(ctx context.Context) error {
	t := time.NewTicker(a.cfg.Jira.PollInterval)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-t.C:
			a.reconcileITSM(ctx)
		}
	}
}

func (a *App) reconcileITSM(ctx context.Context) {
	a.pendingMu.Lock()
	snap := make(map[string]string, len(a.pending))
	for k, v := range a.pending {
		snap[k] = v
	}
	a.pendingMu.Unlock()

	for requestID, ticket := range snap {
		status, err := a.jira.TicketStatus(ctx, ticket)
		if err != nil {
			slog.Warn("ITSM status check failed", "ticket", ticket, "err", err)
			continue
		}
		var next types.RequestState
		switch status {
		case jira.StatusApproved:
			next = types.RequestState_APPROVED
		case jira.StatusRejected:
			next = types.RequestState_DENIED
		default:
			continue
		}
		reason := fmt.Sprintf("televibe: ITSM %s resolved (%s)", ticket, status)
		if err := a.submitReview(ctx, requestID, next, reason); err != nil {
			slog.Error("submit review failed", "request_id", requestID, "err", err)
			continue
		}
		a.pendingMu.Lock()
		delete(a.pending, requestID)
		a.pendingMu.Unlock()
	}
}

func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
	_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
		RequestID: requestID,
		Review: types.AccessReview{
			Author:        "televibe",
			ProposedState: state,
			Reason:        reason,
		},
	})
	return err
}

+  store, err := reputation.NewStore(cfg.Reputation)

+    return nil, fmt.Errorf("reputation store: %w", err)

+    meter, err = telemetry.New(ctx, cfg.Telemetry, store)

+      return nil, fmt.Errorf("telemetry: %w", err)

+// Close flushes outstanding telemetry. Safe to call even if telemetry

+func (a *App) Close(ctx context.Context) error {


Now wire reputation assessment into `handleRequest`.


func (a *App) handleRequest(ctx context.Context, req types.AccessRequest) {
	id := req.GetName()
	log := slog.With("request_id", id, "user", req.GetUser())

	log.Info("scoring access request")
	result, err := a.scorer.Score(ctx, a.ai, req)
	if err != nil {
		log.Error("scoring failed", "err", err)
		return
	}
	log.Info("risk scored",
		"score", result.Score,
		"tier", result.Tier.Name,
		"slang", result.Tier.Slang,
		"components", result.Components)

	if result.Score < a.cfg.Risk.TicketThreshold {

func (a *App) handleRequest(ctx context.Context, req types.AccessRequest) {
	id := req.GetName()
	user := req.GetUser()
	log := slog.With("request_id", id, "user", user)

	log.Info("scoring access request")
	result, err := a.scorer.Score(ctx, a.ai, req)
	if err != nil {
		log.Error("scoring failed", "err", err)
		return
	}
	log.Info("risk scored",
		"score", result.Score,
		"tier", result.Tier.Name,
		"slang", result.Tier.Slang,
		"components", result.Components)

	assessment, err := a.rep.Assess(ctx, user, result.Components)
	if err != nil {
		log.Warn("reputation update failed", "err", err)
	}
	if len(assessment.Penalties) > 0 {
		reasons := make([]string, 0, len(assessment.Penalties))
		for _, p := range assessment.Penalties {
			reasons = append(reasons, p.Reason)
		}
		log.Info("reputation docked",
			"previous", assessment.Previous,
			"current", assessment.Score,
			"reasons", reasons)
	}

	if result.Score < a.cfg.Risk.TicketThreshold {


func (a *App) handleRequest(ctx context.Context, req types.AccessRequest) {
	id := req.GetName()
	user := req.GetUser()
	log := slog.With("request_id", id, "user", user)

	log.Info("scoring access request")
	result, err := a.scorer.Score(ctx, a.ai, req)
	if err != nil {
		log.Error("scoring failed", "err", err)
		return
	}
	log.Info("risk scored",
		"score", result.Score,
		"tier", result.Tier.Name,
		"slang", result.Tier.Slang,
		"components", result.Components)

	assessment, err := a.rep.Assess(ctx, user, result.Components)
	if err != nil {
		log.Warn("reputation update failed", "err", err)
	}
	if len(assessment.Penalties) > 0 {
		reasons := make([]string, 0, len(assessment.Penalties))
		for _, p := range assessment.Penalties {
			reasons = append(reasons, p.Reason)
		}
		log.Info("reputation docked",
			"previous", assessment.Previous,
			"current", assessment.Score,
			"reasons", reasons)
	}

	if result.Score < a.cfg.Risk.TicketThreshold {

package televibe

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

	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
	"golang.org/x/sync/errgroup"

	"televibe/ai"
	"televibe/jira"
	"televibe/reputation"
	"televibe/risk"
	"televibe/telemetry"
)

type App struct {
	cfg    *Config
	tele   *client.Client
	ai     ai.Provider
	jira   *jira.Client
	scorer *risk.Scorer
	rep    *reputation.Store
	meter  *telemetry.Meter

	pending   map[string]string
	pendingMu sync.Mutex
}

func NewApp(ctx context.Context, cfg *Config) (*App, error) {
	tele, err := client.New(ctx, client.Config{
		Addrs: []string{cfg.Teleport.Addr},
		Credentials: []client.Credentials{
			client.LoadIdentityFile(cfg.Teleport.IdentityFile),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("teleport client: %w", err)
	}

	prov, err := ai.New(cfg.AI)
	if err != nil {
		return nil, fmt.Errorf("ai provider: %w", err)
	}

	jc, err := jira.New(cfg.Jira)
	if err != nil {
		return nil, fmt.Errorf("jira client: %w", err)
	}

	scorer, err := risk.NewScorer(cfg.Risk)
	if err != nil {
		return nil, fmt.Errorf("scorer: %w", err)
	}

	store, err := reputation.NewStore(cfg.Reputation)
	if err != nil {
		return nil, fmt.Errorf("reputation store: %w", err)
	}

	var meter *telemetry.Meter
	if cfg.Telemetry.Enabled {
		meter, err = telemetry.New(ctx, cfg.Telemetry, store)
		if err != nil {
			return nil, fmt.Errorf("telemetry: %w", err)
		}
	}

	return &App{
		cfg:     cfg,
		tele:    tele,
		ai:      prov,
		jira:    jc,
		scorer:  scorer,
		rep:     store,
		meter:   meter,
		pending: map[string]string{},
	}, nil
}

// Close flushes outstanding telemetry. Safe to call even if telemetry
// is disabled.
func (a *App) Close(ctx context.Context) error {
	return a.meter.Shutdown(ctx)
}

func (a *App) Run(ctx context.Context) error {
	slog.Info("televibe starting",
		"provider", a.cfg.AI.Provider,
		"threshold", a.cfg.Risk.TicketThreshold,
		"itsm_project", a.cfg.Jira.Project)

	if err := a.rehydrate(ctx); err != nil {
		return fmt.Errorf("rehydrate open ITSM tickets: %w", err)
	}

	g, gctx := errgroup.WithContext(ctx)
	g.Go(func() error { return a.watchRequests(gctx) })
	g.Go(func() error { return a.pollITSM(gctx) })
	return g.Wait()
}

func (a *App) rehydrate(ctx context.Context) error {
	open, err := a.jira.ListOpenTeleportTickets(ctx)
	if err != nil {
		return err
	}
	a.pendingMu.Lock()
	for reqID, ticket := range open {
		a.pending[reqID] = ticket
	}
	a.pendingMu.Unlock()
	slog.Info("rehydrated pending tickets", "count", len(open))
	return nil
}

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

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-watcher.Done():
			return watcher.Error()
		case ev := <-watcher.Events():
			if ev.Type != types.OpPut {
				continue
			}
			req, ok := ev.Resource.(types.AccessRequest)
			if !ok {
				continue
			}
			if req.GetState() != types.RequestState_PENDING {
				continue
			}
			a.pendingMu.Lock()
			_, held := a.pending[req.GetName()]
			a.pendingMu.Unlock()
			if held {
				continue
			}
			go a.handleRequest(ctx, req)
		}
	}
}

func (a *App) handleRequest(ctx context.Context, req types.AccessRequest) {
	id := req.GetName()
	log := slog.With("request_id", id, "user", req.GetUser())

	log.Info("scoring access request")
	result, err := a.scorer.Score(ctx, a.ai, req)
	if err != nil {
		log.Error("scoring failed", "err", err)
		return
	}
	log.Info("risk scored",
		"score", result.Score,
		"tier", result.Tier.Name,
		"slang", result.Tier.Slang,
		"components", result.Components)

	if result.Score < a.cfg.Risk.TicketThreshold {
		reason := fmt.Sprintf("televibe: %s (score %d, %q) — auto-approved",
			result.Tier.Name, result.Score, result.Tier.Slang)
		if err := a.submitReview(ctx, id, types.RequestState_APPROVED, reason); err != nil {
			log.Error("auto-approve failed", "err", err)
		}
		return
	}

	ticket, err := a.jira.CreateITSMTicket(ctx, jira.TicketInput{
		RequestID:   id,
		User:        req.GetUser(),
		Roles:       req.GetRoles(),
		Reason:      req.GetRequestReason(),
		Score:       result.Score,
		Tier:        result.Tier.Name,
		Slang:       result.Tier.Slang,
		Components:  result.Components,
		AIReasoning: result.Reasoning,
	})
	if err != nil {
		log.Error("create ITSM ticket failed", "err", err)
		return
	}

	a.pendingMu.Lock()
	a.pending[id] = ticket
	a.pendingMu.Unlock()
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name, "slang", result.Tier.Slang)
}

func (a *App) pollITSM(ctx context.Context) error {
	t := time.NewTicker(a.cfg.Jira.PollInterval)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-t.C:
			a.reconcileITSM(ctx)
		}
	}
}

func (a *App) reconcileITSM(ctx context.Context) {
	a.pendingMu.Lock()
	snap := make(map[string]string, len(a.pending))
	for k, v := range a.pending {
		snap[k] = v
	}
	a.pendingMu.Unlock()

	for requestID, ticket := range snap {
		status, err := a.jira.TicketStatus(ctx, ticket)
		if err != nil {
			slog.Warn("ITSM status check failed", "ticket", ticket, "err", err)
			continue
		}
		var next types.RequestState
		switch status {
		case jira.StatusApproved:
			next = types.RequestState_APPROVED
		case jira.StatusRejected:
			next = types.RequestState_DENIED
		default:
			continue
		}
		reason := fmt.Sprintf("televibe: ITSM %s resolved (%s)", ticket, status)
		if err := a.submitReview(ctx, requestID, next, reason); err != nil {
			slog.Error("submit review failed", "request_id", requestID, "err", err)
			continue
		}
		a.pendingMu.Lock()
		delete(a.pending, requestID)
		a.pendingMu.Unlock()
	}
}

func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
	_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
		RequestID: requestID,
		Review: types.AccessReview{
			Author:        "televibe",
			ProposedState: state,
			Reason:        reason,
		},
	})
	return err
}

-  log := slog.With("request_id", id, "user", req.GetUser())

+  log := slog.With("request_id", id, "user", user)

+  assessment, err := a.rep.Assess(ctx, user, result.Components)

+    log.Warn("reputation update failed", "err", err)

+    reasons := make([]string, 0, len(assessment.Penalties))

+    for _, p := range assessment.Penalties {

+      reasons = append(reasons, p.Reason)


Now main.go — wire the graceful shutdown.


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

	"televibe"
)

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

	"televibe"
)


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

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

	"televibe"
)

package main

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

	"televibe"
)

func main() {
	configPath := flag.String("config", "/etc/televibe/televibe.toml", "Path to TOML configuration")
	flag.Parse()

	cfg, err := televibe.LoadConfig(*configPath)
	if err != nil {
		slog.Error("config load failed", "path", *configPath, "err", err)
		os.Exit(1)
	}

	setupLogging(cfg.Log.Level)

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

	app, err := televibe.NewApp(ctx, cfg)
	if err != nil {
		slog.Error("app init failed", "err", err)
		os.Exit(1)
	}

	if err := app.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
		slog.Error("app exited with error", "err", err)
		os.Exit(1)
	}
}

func setupLogging(level string) {
	var l slog.Level
	switch level {
	case "debug":
		l = slog.LevelDebug
	case "warn":
		l = slog.LevelWarn
	case "error":
		l = slog.LevelError
	default:
		l = slog.LevelInfo
	}
	h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: l})
	slog.SetDefault(slog.New(h))
}

"os"
"os/signal"
"syscall"
+  "time"
"televibe"
)


Build reputation package with native JSON persistence

Create reputation/reputation.go with Store, Config (configurable penalties/floor/ceiling/path), Assess method, atomic file persistence via rename. Plus tests.

Build telemetry package wiring OTLP meter provider

Create telemetry/telemetry.go with OTel SDK setup, OTLP gRPC exporter, and an observable Int64 gauge that calls back into the reputation store on each collection cycle.

Wire reputation + telemetry into app/config/main

Extend Config, App, and main.go: init reputation store and (optionally) telemetry meter, call Assess in handleRequest, graceful shutdown of meter provider.

Update TOML example and README

Document the new [reputation] and [telemetry] sections and the new component in the data flow / layout.


	if err := app.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
		slog.Error("app exited with error", "err", err)
		os.Exit(1)
	}
}

	runErr := app.Run(ctx)

	shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelShutdown()
	if err := app.Close(shutdownCtx); err != nil {
		slog.Warn("shutdown error", "err", err)
	}

	if runErr != nil && !errors.Is(runErr, context.Canceled) {
		slog.Error("app exited with error", "err", runErr)
		os.Exit(1)
	}
}


	runErr := app.Run(ctx)

	shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelShutdown()
	if err := app.Close(shutdownCtx); err != nil {
		slog.Warn("shutdown error", "err", err)
	}

	if runErr != nil && !errors.Is(runErr, context.Canceled) {
		slog.Error("app exited with error", "err", runErr)
		os.Exit(1)
	}
}

package main

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

	"televibe"
)

func main() {
	configPath := flag.String("config", "/etc/televibe/televibe.toml", "Path to TOML configuration")
	flag.Parse()

	cfg, err := televibe.LoadConfig(*configPath)
	if err != nil {
		slog.Error("config load failed", "path", *configPath, "err", err)
		os.Exit(1)
	}

	setupLogging(cfg.Log.Level)

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

	app, err := televibe.NewApp(ctx, cfg)
	if err != nil {
		slog.Error("app init failed", "err", err)
		os.Exit(1)
	}

	if err := app.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
		slog.Error("app exited with error", "err", err)
		os.Exit(1)
	}
}

func setupLogging(level string) {
	var l slog.Level
	switch level {
	case "debug":
		l = slog.LevelDebug
	case "warn":
		l = slog.LevelWarn
	case "error":
		l = slog.LevelError
	default:
		l = slog.LevelInfo
	}
	h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: l})
	slog.SetDefault(slog.New(h))
}

-  if err := app.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {

-    slog.Error("app exited with error", "err", err)

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

+  if err := app.Close(shutdownCtx); err != nil {

+    slog.Warn("shutdown error", "err", err)

+  if runErr != nil && !errors.Is(runErr, context.Canceled) {

+    slog.Error("app exited with error", "err", runErr)


Bash

Build the full project


BUILD OK


Run all tests with race detector


?   	televibe	[no test files]
?   	televibe/ai	[no test files]
?   	televibe/cmd/televibe	[no test files]
?   	televibe/jira	[no test files]
ok  	televibe/reputation	1.418s
?   	televibe/risk	[no test files]
?   	televibe/telemetry	[no test files]


Run go vet


VET OK


Updated task #3 status


Updated task #4 status


Edit

[risk]
# Requests scoring >= ticket_threshold get an ITSM ticket (and stay PENDING).
# Anything below is auto-approved as "no cap".
ticket_threshold = 25
custx_pattern = 'CUSTX-\d+'
# Off-hours window applies a +10 risk bump. Wraps midnight when start > end.
off_hours_start = 18
off_hours_end = 8
timezone = "America/New_York"

[log]
level = "info"

[risk]
# Requests scoring >= ticket_threshold get an ITSM ticket (and stay PENDING).
# Anything below is auto-approved as "no cap".
ticket_threshold = 25
custx_pattern = 'CUSTX-\d+'
# Off-hours window applies a +10 risk bump. Wraps midnight when start > end.
off_hours_start = 18
off_hours_end = 8
timezone = "America/New_York"

[reputation]
# Per-user reputation, persisted as JSON on local disk. Every user starts
# at starting_score; points are deducted for bad requests and clamped to
# [floor_score, ceiling_score]. Reputation only goes down — the point is
# to shame, not to forgive.
persist_path              = "/var/lib/televibe/reputation.json"
starting_score            = 100
floor_score               = 0
ceiling_score             = 100
penalty_missing_custx     = 10
penalty_thin_description  = 5

[telemetry]
# Emit per-user reputation as an OTLP gauge for the collector to ship to
# Datadog. Disable to keep reputation state local only.
enabled       = true
endpoint      = "otel-collector:4317"
insecure      = true
service_name  = "televibe"
export_period = "15s"

[log]
level = "info"


The file /Users/steve.briggs/projects/operations/apps/televibe/televibe.toml.example has been updated successfully.

[risk]
# Requests scoring >= ticket_threshold get an ITSM ticket (and stay PENDING).
# Anything below is auto-approved as "no cap".
ticket_threshold = 25
custx_pattern = 'CUSTX-\d+'
# Off-hours window applies a +10 risk bump. Wraps midnight when start > end.
off_hours_start = 18
off_hours_end = 8
timezone = "America/New_York"

[reputation]
# Per-user reputation, persisted as JSON on local disk. Every user starts
# at starting_score; points are deducted for bad requests and clamped to
# [floor_score, ceiling_score]. Reputation only goes down — the point is
# to shame, not to forgive.
persist_path              = "/var/lib/televibe/reputation.json"
starting_score            = 100
floor_score               = 0
ceiling_score             = 100
penalty_missing_custx     = 10
penalty_thin_description  = 5

[telemetry]
# Emit per-user reputation as an OTLP gauge for the collector to ship to
# Datadog. Disable to keep reputation state local only.
enabled       = true
endpoint      = "otel-collector:4317"
insecure      = true
service_name  = "televibe"
export_period = "15s"

[log]
level = "info"

# televibe — Teleport access plugin with AI risk scoring
# Copy to /etc/televibe/televibe.toml and fill in.

[teleport]
addr = "proxy.example.com:443"
identity_file = "/var/lib/teleport/televibe.pem"

[ai]
# Exactly one of: "anthropic", "bedrock", "webhook"
provider = "anthropic"

[ai.anthropic]
api_key = "sk-ant-..."
model = "claude-sonnet-4-6"

[ai.bedrock]
region = "us-west-2"
model_id = "us.anthropic.claude-sonnet-4-6-v1:0"

[ai.webhook]
url = "https://chat.internal/score"
auth_header = "Bearer replace-me"
# [ai.webhook.headers]
# X-Tenant = "televibe"

[jira]
url = "https://example.atlassian.net"
username = "bot@example.com"
api_token = "replace-me"
project = "ITSM"
issue_type = "Task"
poll_interval = "30s"
approved_status = "Approved"
rejected_status = "Rejected"

[risk]
# Requests scoring >= ticket_threshold get an ITSM ticket (and stay PENDING).
# Anything below is auto-approved as "no cap".
ticket_threshold = 25
custx_pattern = 'CUSTX-\d+'
# Off-hours window applies a +10 risk bump. Wraps midnight when start > end.
off_hours_start = 18
off_hours_end = 8
timezone = "America/New_York"

[log]
level = "info"

+# Per-user reputation, persisted as JSON on local disk. Every user starts

+# at starting_score; points are deducted for bad requests and clamped to

+# [floor_score, ceiling_score]. Reputation only goes down — the point is

+persist_path              = "/var/lib/televibe/reputation.json"

+# Emit per-user reputation as an OTLP gauge for the collector to ship to

+# Datadog. Disable to keep reputation state local only.


Now the README.


**Risk components:**

- **Structural (deterministic):** missing `CUSTX-###` ticket reference (+30), thin description `<40` chars (+15), off-hours request (+10).
- **AI-judged (clamped):** description quality 0–25, privilege concern 0–25, anomalies 0–10.

**Risk components:**

- **Structural (deterministic):** missing `CUSTX-###` ticket reference (+30), thin description `<40` chars (+15), off-hours request (+10).
- **AI-judged (clamped):** description quality 0–25, privilege concern 0–25, anomalies 0–10.

## User reputation

Every requester starts at `reputation.starting_score` (default `100`) and
accumulates deductions for each bad request they file. Penalties and the
`[floor, ceiling]` clamp are fully configurable:

| Signal                 | Config key                         | Default |
| ---------------------- | ---------------------------------- | ------- |
| Missing CUSTX ticket   | `reputation.penalty_missing_custx` | `10`    |
| Thin (<40 char) reason | `reputation.penalty_thin_description` | `5`  |

State is persisted as a JSON map at `reputation.persist_path` via atomic
rename — no external database required. The file survives restarts; leave
`persist_path` empty to keep everything in memory.

Reputation only ever goes down. If somebody wants their number back,
they can file better requests next quarter. Or not.

## Telemetry / shaming pipeline

When `telemetry.enabled = true`, televibe stands up an OpenTelemetry
`MeterProvider` with an OTLP/gRPC exporter pointed at the configured
collector. It registers one observable gauge:

- `televibe.user.reputation` — int64, tagged with `user`, observed every
  `telemetry.export_period` (default `15s`).

Route your collector's output to Datadog (or Prometheus, or wherever) and
the gauge lands tagged per user, ready to be pinned to a dashboard for
the monthly team meeting.


The file /Users/steve.briggs/projects/operations/apps/televibe/README.md has been updated successfully.

**Risk components:**

- **Structural (deterministic):** missing `CUSTX-###` ticket reference (+30), thin description `<40` chars (+15), off-hours request (+10).
- **AI-judged (clamped):** description quality 0–25, privilege concern 0–25, anomalies 0–10.

## User reputation

Every requester starts at `reputation.starting_score` (default `100`) and
accumulates deductions for each bad request they file. Penalties and the
`[floor, ceiling]` clamp are fully configurable:

| Signal                 | Config key                         | Default |
| ---------------------- | ---------------------------------- | ------- |
| Missing CUSTX ticket   | `reputation.penalty_missing_custx` | `10`    |
| Thin (<40 char) reason | `reputation.penalty_thin_description` | `5`  |

State is persisted as a JSON map at `reputation.persist_path` via atomic
rename — no external database required. The file survives restarts; leave
`persist_path` empty to keep everything in memory.

Reputation only ever goes down. If somebody wants their number back,
they can file better requests next quarter. Or not.

## Telemetry / shaming pipeline

When `telemetry.enabled = true`, televibe stands up an OpenTelemetry
`MeterProvider` with an OTLP/gRPC exporter pointed at the configured
collector. It registers one observable gauge:

- `televibe.user.reputation` — int64, tagged with `user`, observed every
  `telemetry.export_period` (default `15s`).

Route your collector's output to Datadog (or Prometheus, or wherever) and
the gauge lands tagged per user, ready to be pinned to a dashboard for
the monthly team meeting.

# televibe

A [Teleport](https://goteleport.com) access-request plugin that routes each pending request through an AI risk analyst, auto-approves the boring ones, and files an ITSM Jira ticket for the sketchy ones — with the Teleport request held in `PENDING` until an SRE resolves the ticket.

Risk tiers are reported in gen-z slang because audit logs should spark joy:

| Score  | Tier     | Slang                     | Action                                 |
| ------ | -------- | ------------------------- | -------------------------------------- |
| 0–24   | low      | `no cap`                  | auto-approve, no Jira ticket           |
| 25–49  | medium   | `sus`                     | file ITSM ticket, hold request pending |
| 50–74  | high     | `mad sus fr`              | file ITSM ticket with tier label       |
| 75–100 | critical | `straight L, touch grass` | file ITSM ticket with tier label       |

The ticket-creation threshold is configurable (`risk.ticket_threshold`).

## How it works

```
Teleport ──► watcher ──► scorer ──► AI provider ──► tier
                             │
                             ├── below threshold ──► auto-approve via AccessReview
                             │
                             └── at/above threshold ──► Jira ITSM ticket ──► poll status
                                                                              │
                                                         SRE approves/rejects │
                                                                              ▼
                                                              Teleport AccessReview
```

**Risk components:**

- **Structural (deterministic):** missing `CUSTX-###` ticket reference (+30), thin description `<40` chars (+15), off-hours request (+10).
- **AI-judged (clamped):** description quality 0–25, privilege concern 0–25, anomalies 0–10.

## AI providers

Pick one via `ai.provider`:

- **`anthropic`** — direct call to the Anthropic Messages API. System prompt is sent with `cache_control: ephemeral` so repeated scoring hits the prompt cache.
- **`bedrock`** — `bedrockruntime:InvokeModel` against a Claude-on-Bedrock model. Uses the default AWS credential chain.
- **`webhook`** — generic `POST` of `{system_prompt, user_prompt, request}` to any URL you point it at (ChatGPT custom GPT action, n8n workflow, internal scoring service). The response must be JSON matching the `Assessment` shape.

## Install

```sh
go mod tidy
go build -o televibe ./cmd/televibe
```

## Configure

Copy `televibe.toml.example` to `/etc/televibe/televibe.toml` and fill in:

- `[teleport]` — proxy address + path to an identity file for a Teleport user with permission to watch access requests and submit reviews.
- `[ai]` — pick a provider and supply its credentials.
- `[jira]` — API user, token, ITSM project key, and the status names to treat as approved/rejected (e.g. `"Approved"` / `"Rejected"`).
- `[risk]` — threshold, CUSTX pattern (defaults to `CUSTX-\d+`), off-hours window, timezone.

### Teleport role

The plugin user needs a role roughly like:

```yaml
kind: role
version: v7
metadata:
  name: televibe-plugin
spec:
  allow:
    rules:
      - resources: [access_request]
        verbs: [list, read, update]
      - resources: [access_plugin_data]
        verbs: [update]
```

Issue an identity file with `tctl auth sign --user=televibe --out=/var/lib/teleport/televibe.pem --ttl=8760h --format=file`.

### Jira workflow

The ITSM project needs statuses matching `jira.approved_status` and `jira.rejected_status`. When an SRE transitions the ticket to either, the poller reflects that decision back to the Teleport request on its next tick (`jira.poll_interval`, default `30s`).

## Run

```sh
./televibe --config /etc/televibe/televibe.toml
```

JSON logs to stderr, level from `[log].level`.

## Layout

```
televibe/
├── cmd/televibe/main.go    entry point
├── config.go               TOML loader + validation
├── app.go                  watcher loop + ITSM poller
├── ai/                     Provider interface, Anthropic / Bedrock / webhook impls
├── risk/                   scorer, tier table, slang
├── jira/itsm.go            ticket create, status poll, restart rehydrate via JQL
└── televibe.toml.example
```

## Restart safety

On startup, `televibe` runs a JQL search for open tickets labelled `televibe` in the ITSM project and pulls the Teleport request ID out of each ticket description to rebuild the held-request map. Restarts don't duplicate tickets.

## License

TBD.

+Every requester starts at `reputation.starting_score` (default `100`) and

+accumulates deductions for each bad request they file. Penalties and the

+`[floor, ceiling]` clamp are fully configurable:

+| Signal                 | Config key                         | Default |

+| Missing CUSTX ticket   | `reputation.penalty_missing_custx` | `10`    |

+| Thin (<40 char) reason | `reputation.penalty_thin_description` | `5`  |

+State is persisted as a JSON map at `reputation.persist_path` via atomic

+rename — no external database required. The file survives restarts; leave

+`persist_path` empty to keep everything in memory.

+Reputation only ever goes down. If somebody wants their number back,

+they can file better requests next quarter. Or not.

+When `telemetry.enabled = true`, televibe stands up an OpenTelemetry

+`MeterProvider` with an OTLP/gRPC exporter pointed at the configured

+collector. It registers one observable gauge:

+- `televibe.user.reputation` — int64, tagged with `user`, observed every

+  `telemetry.export_period` (default `15s`).

+Route your collector's output to Datadog (or Prometheus, or wherever) and

+the gauge lands tagged per user, ready to be pinned to a dashboard for


```
televibe/
├── cmd/televibe/main.go    entry point
├── config.go               TOML loader + validation
├── app.go                  watcher loop + ITSM poller
├── ai/                     Provider interface, Anthropic / Bedrock / webhook impls
├── risk/                   scorer, tier table, slang
├── jira/itsm.go            ticket create, status poll, restart rehydrate via JQL
└── televibe.toml.example
```

```
televibe/
├── cmd/televibe/main.go    entry point
├── config.go               TOML loader + validation
├── app.go                  watcher loop + ITSM poller
├── ai/                     Provider interface, Anthropic / Bedrock / webhook impls
├── risk/                   scorer, tier table, slang
├── jira/itsm.go            ticket create, status poll, restart rehydrate via JQL
├── reputation/             per-user score store, JSON-file persistence
├── telemetry/              OTLP meter provider, observable reputation gauge
└── televibe.toml.example
```


```
televibe/
├── cmd/televibe/main.go    entry point
├── config.go               TOML loader + validation
├── app.go                  watcher loop + ITSM poller
├── ai/                     Provider interface, Anthropic / Bedrock / webhook impls
├── risk/                   scorer, tier table, slang
├── jira/itsm.go            ticket create, status poll, restart rehydrate via JQL
├── reputation/             per-user score store, JSON-file persistence
├── telemetry/              OTLP meter provider, observable reputation gauge
└── televibe.toml.example
```

# televibe

A [Teleport](https://goteleport.com) access-request plugin that routes each pending request through an AI risk analyst, auto-approves the boring ones, and files an ITSM Jira ticket for the sketchy ones — with the Teleport request held in `PENDING` until an SRE resolves the ticket.

Risk tiers are reported in gen-z slang because audit logs should spark joy:

| Score  | Tier     | Slang                     | Action                                 |
| ------ | -------- | ------------------------- | -------------------------------------- |
| 0–24   | low      | `no cap`                  | auto-approve, no Jira ticket           |
| 25–49  | medium   | `sus`                     | file ITSM ticket, hold request pending |
| 50–74  | high     | `mad sus fr`              | file ITSM ticket with tier label       |
| 75–100 | critical | `straight L, touch grass` | file ITSM ticket with tier label       |

The ticket-creation threshold is configurable (`risk.ticket_threshold`).

## How it works

```
Teleport ──► watcher ──► scorer ──► AI provider ──► tier
                             │
                             ├── below threshold ──► auto-approve via AccessReview
                             │
                             └── at/above threshold ──► Jira ITSM ticket ──► poll status
                                                                              │
                                                         SRE approves/rejects │
                                                                              ▼
                                                              Teleport AccessReview
```

**Risk components:**

- **Structural (deterministic):** missing `CUSTX-###` ticket reference (+30), thin description `<40` chars (+15), off-hours request (+10).
- **AI-judged (clamped):** description quality 0–25, privilege concern 0–25, anomalies 0–10.

## User reputation

Every requester starts at `reputation.starting_score` (default `100`) and
accumulates deductions for each bad request they file. Penalties and the
`[floor, ceiling]` clamp are fully configurable:

| Signal                 | Config key                         | Default |
| ---------------------- | ---------------------------------- | ------- |
| Missing CUSTX ticket   | `reputation.penalty_missing_custx` | `10`    |
| Thin (<40 char) reason | `reputation.penalty_thin_description` | `5`  |

State is persisted as a JSON map at `reputation.persist_path` via atomic
rename — no external database required. The file survives restarts; leave
`persist_path` empty to keep everything in memory.

Reputation only ever goes down. If somebody wants their number back,
they can file better requests next quarter. Or not.

## Telemetry / shaming pipeline

When `telemetry.enabled = true`, televibe stands up an OpenTelemetry
`MeterProvider` with an OTLP/gRPC exporter pointed at the configured
collector. It registers one observable gauge:

- `televibe.user.reputation` — int64, tagged with `user`, observed every
  `telemetry.export_period` (default `15s`).

Route your collector's output to Datadog (or Prometheus, or wherever) and
the gauge lands tagged per user, ready to be pinned to a dashboard for
the monthly team meeting.

## AI providers

Pick one via `ai.provider`:

- **`anthropic`** — direct call to the Anthropic Messages API. System prompt is sent with `cache_control: ephemeral` so repeated scoring hits the prompt cache.
- **`bedrock`** — `bedrockruntime:InvokeModel` against a Claude-on-Bedrock model. Uses the default AWS credential chain.
- **`webhook`** — generic `POST` of `{system_prompt, user_prompt, request}` to any URL you point it at (ChatGPT custom GPT action, n8n workflow, internal scoring service). The response must be JSON matching the `Assessment` shape.

## Install

```sh
go mod tidy
go build -o televibe ./cmd/televibe
```

## Configure

Copy `televibe.toml.example` to `/etc/televibe/televibe.toml` and fill in:

- `[teleport]` — proxy address + path to an identity file for a Teleport user with permission to watch access requests and submit reviews.
- `[ai]` — pick a provider and supply its credentials.
- `[jira]` — API user, token, ITSM project key, and the status names to treat as approved/rejected (e.g. `"Approved"` / `"Rejected"`).
- `[risk]` — threshold, CUSTX pattern (defaults to `CUSTX-\d+`), off-hours window, timezone.

### Teleport role

The plugin user needs a role roughly like:

```yaml
kind: role
version: v7
metadata:
  name: televibe-plugin
spec:
  allow:
    rules:
      - resources: [access_request]
        verbs: [list, read, update]
      - resources: [access_plugin_data]
        verbs: [update]
```

Issue an identity file with `tctl auth sign --user=televibe --out=/var/lib/teleport/televibe.pem --ttl=8760h --format=file`.

### Jira workflow

The ITSM project needs statuses matching `jira.approved_status` and `jira.rejected_status`. When an SRE transitions the ticket to either, the poller reflects that decision back to the Teleport request on its next tick (`jira.poll_interval`, default `30s`).

## Run

```sh
./televibe --config /etc/televibe/televibe.toml
```

JSON logs to stderr, level from `[log].level`.

## Layout

```
televibe/
├── cmd/televibe/main.go    entry point
├── config.go               TOML loader + validation
├── app.go                  watcher loop + ITSM poller
├── ai/                     Provider interface, Anthropic / Bedrock / webhook impls
├── risk/                   scorer, tier table, slang
├── jira/itsm.go            ticket create, status poll, restart rehydrate via JQL
└── televibe.toml.example
```

## Restart safety

On startup, `televibe` runs a JQL search for open tickets labelled `televibe` in the ITSM project and pulls the Teleport request ID out of each ticket description to rebuild the held-request map. Restarts don't duplicate tickets.

## License

TBD.

├── risk/                   scorer, tier table, slang

+├── reputation/             per-user score store, JSON-file persistence

+├── telemetry/              OTLP meter provider, observable reputation gauge


One final build + test sweep to be sure nothing drifted.


Bash

Final build, vet, and test pass


?   	televibe	[no test files]
?   	televibe/ai	[no test files]
?   	televibe/cmd/televibe	[no test files]
?   	televibe/jira	[no test files]
ok  	televibe/reputation	(cached)
?   	televibe/risk	[no test files]
?   	televibe/telemetry	[no test files]


All clean, Sir Charles of St Cheese. Here's what changed:

**New packages**
- `reputation/` — thread-safe `Store` with `map[string]int`, atomic JSON-file persistence (temp file + rename, `0600` perms), configurable penalties, floor/ceiling clamp. Tests pass under `-race`.
- `telemetry/` — stands up an OTel `MeterProvider` + OTLP/gRPC exporter with an `Int64ObservableGauge` (`televibe.user.reputation`) that calls `Store.Range` on every collection cycle. Each observation carries a `user` attribute, so Datadog gets one tagged time series per requester.

**Wiring**
- `Config` gains `[reputation]` and `[telemetry]` sections, both with defaults and the former with validation.
- `App` now owns the store and (optionally) the meter; `handleRequest` calls `rep.Assess(ctx, user, result.Components)` right after scoring and logs a `reputation docked` line when anything's deducted.
- `main.go` calls `app.Close(shutdownCtx)` with a 5s budget so the OTLP exporter flushes cleanly.

**Scope choices you should sanity-check**
- Only the deterministic `missing_custx_ticket` and `thin_description` components trigger reputation deductions. I deliberately did **not** key off the AI's `description_quality` score because it's non-deterministic and would make the numbers twitchy. Easy to add if you want it.
- Reputation **only decreases**. You said "gradually get worse," and forgiveness wasn't on the menu. If you ever want a slow recovery tick, it's a one-liner in `reputation/reputation.go`.
- `persist_path` is optional — set to empty string for ephemeral in-memory state.

**Deps added:** `go.opentelemetry.io/otel` + `sdk/metric` + `exporters/otlp/otlpmetric/otlpmetricgrpc` + `semconv/v1.24.0`. The old `go.mod` had *no* dependencies tracked, so `go mod tidy` pulled in everything the prior code already imported too — your previous commit was not technically in a buildable state, but it is now. You're welcome.


write a good github commit message for these updates


Review recent commit style


fc6d8de televibe
2de4d2b ci(SRE-2757): crowctl and Terraform CI workflow (#186)
1d4f5f1 chore(bot): sync mise folder from upstream commit 51247f5a (#231)
d84d530 chore(ITSM-9198): allow DAG web pod ingress from policy synthetics
142fcb8 chore(bot): sync mise folder from upstream commit 6d6194c7 (#230)
29ef3c1 chart version++
70e2e85 chore(ITSM-9198): allow DAG web pod ingress from policy synthetics
c7343e1 Merge pull request #226 from immuta/buildkit-static-replicas-no-hpa
79f8554 Fixes numbers
4620da0 Set the replicas static and remove hpa scaling
7437d7e Merge pull request #225 from immuta/buildkit-memory-change-again-part2
a7b9551 Update memory resources so the pod will schedule - hopefully
b07ffe1 Merge pull request #224 from immuta/buildkit-cpu-change
e8d43b5 Update deployments container resources to 2.5 from 3
841c36a Merge pull request #223 from immuta/change-buildkit-fleet-memory
43f4685 Change memory limit to 14
e84b61d Merge pull request #221 from immuta/adjust-hpa-bahavior-scaledown
812b0bb Update resources to use the whole node
3fc34a6 Update HPA to not scal down so quickly
192b7ab feat: Add databricks GCP zone and autozone CI (#220)


```
feat(televibe): add per-user reputation scoring with OTLP export

Track requester reputation across sessions so we can publicly shame the
folks who keep submitting drive-by access requests without a CUSTX ticket
or a sentence of context.

New packages:
- reputation: thread-safe Store with configurable starting score, floor,
  ceiling, and per-signal penalties. State persists to a local JSON file
  via atomic rename (0600), so no external database is required. Every
  user starts neutral; scores only ever decrease.
- telemetry: OpenTelemetry MeterProvider + OTLP/gRPC exporter. Publishes
  televibe.user.reputation as an Int64ObservableGauge tagged with the
  requester's user, ready for the collector to forward to Datadog.

Wiring:
- Config gains [reputation] and [telemetry] sections with defaults +
  validation (floor <= ceiling, starting score within bounds).
- handleRequest now calls rep.Assess after scoring; deductions are
  triggered by the deterministic missing_custx_ticket and thin_description
  components only, to keep the numbers stable.
- main.go calls App.Close with a 5s budget so the exporter flushes on
  shutdown.

Dependencies:
- go.opentelemetry.io/otel + sdk/metric + exporters/otlp/otlpmetric/otlpmetricgrpc
  pulled in via go mod tidy. The module also gained the full set of
  dependencies that the initial televibe commit had referenced but never
  resolved.

Tests: reputation/ has race-enabled unit coverage for defaults, penalty
application, floor clamping, persistence round-trip, validation, and
Range iteration.
```

Drop the last paragraph if you want it tighter — the dependency note is mostly there to preempt the "why does go.sum suddenly exist" question at review time.


Added per-user reputation scoring to televibe with native JSON persistence and OTLP export for Datadog shaming. Build, vet, and race-enabled tests all pass. Next: commit the changes using the drafted message when you're ready. (disable recaps in /config)


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


<local-command-stdout>Stats dialog dismissed</local-command-stdout>


I need to test this, but I need to test it locally. I have docker desktop instealled on my laptop can you setup a local teleport instance in docker and help me test it there?


9		"os/signal"
10		"syscall"
11		"time"
12
13		"televibe"
14	)
15
16	func main() {
17		configPath := flag.String("config", "televibe.toml", "Path to TOML configuration")
18		flag.Parse()
19
20		cfg, err := televibe.LoadConfig(*configPath)
21		if err != nil {
22			slog.Error("config load failed", "path", *configPath, "err", err)
23			os.Exit(1)
24		}
25


Let me peek at a couple of the existing files so I can size the harness correctly.


1	package ai
2
3	import (
4		"bytes"
5		"context"
6		"encoding/json"
7		"fmt"
8		"io"
9		"net/http"
10	)
11
12	type WebhookConfig struct {
13		URL        string            `toml:"url"`
14		AuthHeader string            `toml:"auth_header"`
15		Headers    map[string]string `toml:"headers"`
16	}
17
18	type Webhook struct {
19		cfg    WebhookConfig
20		client *http.Client
21	}
22
23	func NewWebhook(cfg WebhookConfig) *Webhook {
24		return &Webhook{cfg: cfg, client: &http.Client{}}
25	}
26
27	type webhookPayload struct {
28		SystemPrompt string       `json:"system_prompt"`
29		UserPrompt   string       `json:"user_prompt"`
30		Request      ScoreRequest `json:"request"`
31	}
32
33	func (w *Webhook) Assess(ctx context.Context, req ScoreRequest) (*Assessment, error) {
34		body, err := json.Marshal(webhookPayload{
35			SystemPrompt: SystemPrompt,
36			UserPrompt:   UserPrompt(req),
37			Request:      req,
38		})
39		if err != nil {
40			return nil, err
41		}
42		httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, w.cfg.URL, bytes.NewReader(body))
43		if err != nil {
44			return nil, err
45		}
46		httpReq.Header.Set("Content-Type", "application/json")
47		if w.cfg.AuthHeader != "" {
48			httpReq.Header.Set("Authorization", w.cfg.AuthHeader)
49		}
50		for k, v := range w.cfg.Headers {
51			httpReq.Header.Set(k, v)
52		}
53		resp, err := w.client.Do(httpReq)
54		if err != nil {
55			return nil, err
56		}
57		defer resp.Body.Close()
58		if resp.StatusCode >= 300 {
59			raw, _ := io.ReadAll(resp.Body)
60			return nil, fmt.Errorf("webhook %d: %s", resp.StatusCode, raw)
61		}
62		var a Assessment
63		if err := json.NewDecoder(resp.Body).Decode(&a); err != nil {
64			return nil, fmt.Errorf("webhook decode: %w", err)
65		}
66		return &a, nil
67	}
68

package ai

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

type WebhookConfig struct {
	URL        string            `toml:"url"`
	AuthHeader string            `toml:"auth_header"`
	Headers    map[string]string `toml:"headers"`
}

type Webhook struct {
	cfg    WebhookConfig
	client *http.Client
}

func NewWebhook(cfg WebhookConfig) *Webhook {
	return &Webhook{cfg: cfg, client: &http.Client{}}
}

type webhookPayload struct {
	SystemPrompt string       `json:"system_prompt"`
	UserPrompt   string       `json:"user_prompt"`
	Request      ScoreRequest `json:"request"`
}

func (w *Webhook) Assess(ctx context.Context, req ScoreRequest) (*Assessment, error) {
	body, err := json.Marshal(webhookPayload{
		SystemPrompt: SystemPrompt,
		UserPrompt:   UserPrompt(req),
		Request:      req,
	})
	if err != nil {
		return nil, err
	}
	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, w.cfg.URL, bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	httpReq.Header.Set("Content-Type", "application/json")
	if w.cfg.AuthHeader != "" {
		httpReq.Header.Set("Authorization", w.cfg.AuthHeader)
	}
	for k, v := range w.cfg.Headers {
		httpReq.Header.Set(k, v)
	}
	resp, err := w.client.Do(httpReq)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode >= 300 {
		raw, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("webhook %d: %s", resp.StatusCode, raw)
	}
	var a Assessment
	if err := json.NewDecoder(resp.Body).Decode(&a); err != nil {
		return nil, fmt.Errorf("webhook decode: %w", err)
	}
	return &a, nil
}


Before I scatter files everywhere, here's what I'm proposing, Sir Charles. A `local/` harness with docker-compose for **Teleport + OTel collector + a stub webhook AI**, plus a tiny `cmd/submit` helper so you can fire fake access requests from the host. Jira will be stubbed by cranking `ticket_threshold` high enough that nothing ever hits it — your reputation code runs before that branch anyway, so the test loop is intact. Shout if you'd rather I also mock Jira.

Executing now.


Confirm layout before adding local/


drwxr-xr-x steve.briggs staff 288 B  Wed Apr 22 16:44:47 2026 ..
drwxr-xr-x steve.briggs staff  96 B  Wed Apr 22 16:44:47 2026 cmd
drwxr-xr-x steve.briggs staff  96 B  Wed Apr 22 16:44:47 2026 jira
.rw-r--r-- steve.briggs staff 4.0 MB Wed Apr 22 16:44:47 2026 logo.png
drwxr-xr-x steve.briggs staff 192 B  Wed Apr 22 16:44:47 2026 ai
drwxr-xr-x steve.briggs staff 128 B  Wed Apr 22 16:44:47 2026 risk
drwxr-xr-x steve.briggs staff 128 B  Thu Apr 23 10:56:08 2026 reputation
drwxr-xr-x steve.briggs staff  96 B  Thu Apr 23 10:57:30 2026 telemetry
.rw-r--r-- steve.briggs staff  18 KB Thu Apr 23 10:58:00 2026 go.sum
.rw-r--r-- steve.briggs staff 3.6 KB Thu Apr 23 10:58:00 2026 go.mod
.rw-r--r-- steve.briggs staff 1.7 KB Thu Apr 23 10:58:58 2026 config.go
.rw-r--r-- steve.briggs staff 6.5 KB Thu Apr 23 11:01:05 2026 app.go
.rw-r--r-- steve.briggs staff 1.8 KB Thu Apr 23 11:02:52 2026 televibe.toml.example
.rw-r--r-- steve.briggs staff 6.1 KB Thu Apr 23 11:03:30 2026 README.md
drwxr-xr-x steve.briggs staff  96 B  Thu Apr 23 11:03:39 2026 .claude
.rw-r--r-- steve.briggs staff 1.8 KB Thu Apr 23 11:28:11 2026 televibe.toml
drwxr-xr-x steve.briggs staff 576 B  Thu Apr 23 11:28:13 2026 .
.rw-r--r-- steve.briggs staff  12 KB Thu Apr 23 11:28:17 2026 .televibe.toml.swp


Starting with the docker-compose core.


Write

# Local test harness for televibe.
#
# - teleport:       single-node auth+proxy cluster, self-signed TLS.
# - otel-collector: receives OTLP/gRPC on :4317 and prints metrics to stdout
#                   so you can watch reputation flow past in the logs.
# - mock-ai:        stub webhook that returns a canned AI assessment, so the
#                   scorer has something to chew on without real API keys.
#
# televibe itself runs on the host (go run ./cmd/televibe) so you can iterate
# without rebuilding a container on every change.

services:
  teleport:
    image: public.ecr.aws/gravitational/teleport-distroless:17
    container_name: televibe-teleport
    command: ["start", "-c", "/etc/teleport/teleport.yaml"]
    ports:
      - "3080:3080"   # proxy web / tls-routed gRPC
      - "3025:3025"   # auth server (handy for debugging with tctl from host)
    volumes:
      - ./teleport.yaml:/etc/teleport/teleport.yaml:ro
      - ./resources.yaml:/etc/teleport/resources.yaml:ro
      - ./identity:/identity
      - teleport-data:/var/lib/teleport

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.109.0
    container_name: televibe-otel
    command: ["--config=/etc/otel/config.yaml"]
    ports:
      - "4317:4317"   # OTLP/gRPC
    volumes:
      - ./otel-collector.yaml:/etc/otel/config.yaml:ro

  mock-ai:
    image: golang:1.22-alpine
    container_name: televibe-mockai
    working_dir: /src
    command: ["go", "run", "."]
    ports:
      - "8080:8080"
    volumes:
      - ./mock-ai:/src

volumes:
  teleport-data:


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/local/docker-compose.yml

# Local test harness for televibe.
#
# - teleport:       single-node auth+proxy cluster, self-signed TLS.
# - otel-collector: receives OTLP/gRPC on :4317 and prints metrics to stdout
#                   so you can watch reputation flow past in the logs.
# - mock-ai:        stub webhook that returns a canned AI assessment, so the
#                   scorer has something to chew on without real API keys.
#
# televibe itself runs on the host (go run ./cmd/televibe) so you can iterate
# without rebuilding a container on every change.

services:
  teleport:
    image: public.ecr.aws/gravitational/teleport-distroless:17
    container_name: televibe-teleport
    command: ["start", "-c", "/etc/teleport/teleport.yaml"]
    ports:
      - "3080:3080"   # proxy web / tls-routed gRPC
      - "3025:3025"   # auth server (handy for debugging with tctl from host)
    volumes:
      - ./teleport.yaml:/etc/teleport/teleport.yaml:ro
      - ./resources.yaml:/etc/teleport/resources.yaml:ro
      - ./identity:/identity
      - teleport-data:/var/lib/teleport

  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.109.0
    container_name: televibe-otel
    command: ["--config=/etc/otel/config.yaml"]
    ports:
      - "4317:4317"   # OTLP/gRPC
    volumes:
      - ./otel-collector.yaml:/etc/otel/config.yaml:ro

  mock-ai:
    image: golang:1.22-alpine
    container_name: televibe-mockai
    working_dir: /src
    command: ["go", "run", "."]
    ports:
      - "8080:8080"
    volumes:
      - ./mock-ai:/src

volumes:
  teleport-data:


TaskCreate

Build local docker test harness

Create local/ directory with docker-compose (teleport + otel-collector + mock-ai), teleport config, resources yaml, bootstrap script, mock-ai stub, local televibe.toml, and a cmd/submit helper to fire test requests from the host.


Task #5 created successfully: Build local docker test harness

Build local docker test harness


Updated task #5 status


Write

version: v3
teleport:
  nodename: teleport
  data_dir: /var/lib/teleport
  log:
    output: stderr
    severity: INFO
auth_service:
  enabled: true
  cluster_name: localhost
  listen_addr: 0.0.0.0:3025
  authentication:
    type: local
    second_factor: "off"
proxy_service:
  enabled: true
  listen_addr: 0.0.0.0:3023
  public_addrs: ["localhost:3080"]
  web_listen_addr: 0.0.0.0:3080
ssh_service:
  enabled: false


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/local/teleport.yaml

version: v3
teleport:
  nodename: teleport
  data_dir: /var/lib/teleport
  log:
    output: stderr
    severity: INFO
auth_service:
  enabled: true
  cluster_name: localhost
  listen_addr: 0.0.0.0:3025
  authentication:
    type: local
    second_factor: "off"
proxy_service:
  enabled: true
  listen_addr: 0.0.0.0:3023
  public_addrs: ["localhost:3080"]
  web_listen_addr: 0.0.0.0:3080
ssh_service:
  enabled: false


# Roles + users bootstrapped into the local test cluster.
#
# televibe          — plugin account (watches + reviews access requests)
# charles           — human-ish test requester
# privileged        — the role charles escalates to (for shaming purposes)

kind: role
version: v7
metadata:
  name: televibe-plugin
spec:
  allow:
    rules:
      - resources: [access_request]
        verbs: [list, read, update]
      - resources: [access_plugin_data]
        verbs: [update]
---
kind: user
version: v2
metadata:
  name: televibe
spec:
  roles: [televibe-plugin]
---
kind: role
version: v7
metadata:
  name: privileged
spec:
  allow:
    logins: [root]
---
kind: role
version: v7
metadata:
  name: requester
spec:
  allow:
    request:
      roles: [privileged]
      search_as_roles: [privileged]
---
kind: user
version: v2
metadata:
  name: charles
spec:
  roles: [requester]


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/local/resources.yaml

# Roles + users bootstrapped into the local test cluster.
#
# televibe          — plugin account (watches + reviews access requests)
# charles           — human-ish test requester
# privileged        — the role charles escalates to (for shaming purposes)

kind: role
version: v7
metadata:
  name: televibe-plugin
spec:
  allow:
    rules:
      - resources: [access_request]
        verbs: [list, read, update]
      - resources: [access_plugin_data]
        verbs: [update]
---
kind: user
version: v2
metadata:
  name: televibe
spec:
  roles: [televibe-plugin]
---
kind: role
version: v7
metadata:
  name: privileged
spec:
  allow:
    logins: [root]
---
kind: role
version: v7
metadata:
  name: requester
spec:
  allow:
    request:
      roles: [privileged]
      search_as_roles: [privileged]
---
kind: user
version: v2
metadata:
  name: charles
spec:
  roles: [requester]


# Receives OTLP from televibe and prints metrics to stdout.
# Replace the debug exporter with datadog/otlphttp/whatever in staging.
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    metrics:
      receivers: [otlp]
      exporters: [debug]


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/local/otel-collector.yaml

# Receives OTLP from televibe and prints metrics to stdout.
# Replace the debug exporter with datadog/otlphttp/whatever in staging.
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    metrics:
      receivers: [otlp]
      exporters: [debug]


// mock-ai is a stub webhook that returns a canned assessment, so the
// scorer has something to chew on without burning real API keys.
//
// The reputation deductions are driven by the deterministic components
// (missing_custx_ticket, thin_description), so a fixed AI response is
// sufficient for testing the reputation + telemetry pipeline.
package main

import (
	"encoding/json"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/score", func(w http.ResponseWriter, r *http.Request) {
		log.Printf("mock-ai: %s %s", r.Method, r.URL.Path)
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(map[string]any{
			"description_quality": 15,
			"privilege_concern":   10,
			"anomalies":           3,
			"reasoning":           "mock-ai: canned response for local testing",
		})
	})
	log.Println("mock-ai listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/local/mock-ai/main.go

// mock-ai is a stub webhook that returns a canned assessment, so the
// scorer has something to chew on without burning real API keys.
//
// The reputation deductions are driven by the deterministic components
// (missing_custx_ticket, thin_description), so a fixed AI response is
// sufficient for testing the reputation + telemetry pipeline.
package main

import (
	"encoding/json"
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/score", func(w http.ResponseWriter, r *http.Request) {
		log.Printf("mock-ai: %s %s", r.Method, r.URL.Path)
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(map[string]any{
			"description_quality": 15,
			"privilege_concern":   10,
			"anomalies":           3,
			"reasoning":           "mock-ai: canned response for local testing",
		})
	})
	log.Println("mock-ai listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}


module mock-ai

go 1.22


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/local/mock-ai/go.mod

module mock-ai

go 1.22


#!/usr/bin/env bash
# Brings the local stack up and mints identity files for the plugin + the
# test requester. Idempotent — re-run whenever identities expire (24h).
set -euo pipefail

cd "$(dirname "$0")"

COMPOSE="${COMPOSE:-docker compose}"
mkdir -p identity

echo ">> bringing stack up"
$COMPOSE up -d teleport otel-collector mock-ai

echo ">> waiting for teleport to become ready"
for i in $(seq 1 60); do
  if $COMPOSE exec -T teleport tctl status >/dev/null 2>&1; then
    echo ">> teleport ready"
    break
  fi
  sleep 1
  if [[ $i -eq 60 ]]; then
    echo "!! teleport did not become ready in 60s" >&2
    $COMPOSE logs teleport | tail -40
    exit 1
  fi
done

echo ">> applying roles + users"
$COMPOSE exec -T teleport tctl create -f /etc/teleport/resources.yaml

echo ">> issuing identity files"
$COMPOSE exec -T teleport tctl auth sign \
  --user=televibe --format=file --ttl=24h --out=/identity/televibe.pem --overwrite
$COMPOSE exec -T teleport tctl auth sign \
  --user=charles  --format=file --ttl=24h --out=/identity/charles.pem  --overwrite

# tctl writes as root inside the container; make sure the host user can read.
chmod 644 identity/*.pem || true

echo
echo "all set. identity files:"
ls -1 identity/
echo
echo "next steps:"
echo "  # 1. tail metrics"
echo "  docker compose -f local/docker-compose.yml logs -f otel-collector"
echo "  # 2. start televibe from the repo root:"
echo "  go run ./cmd/televibe --config local/televibe.toml"
echo "  # 3. fire a bad request (no CUSTX, thin reason):"
echo "  go run ./cmd/submit --reason 'plz'"
echo "  # 4. fire a clean one:"
echo "  go run ./cmd/submit --reason 'CUSTX-1234 debug prod-api p95 spike, repro attached in ticket'"


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/local/bootstrap.sh

#!/usr/bin/env bash
# Brings the local stack up and mints identity files for the plugin + the
# test requester. Idempotent — re-run whenever identities expire (24h).
set -euo pipefail

cd "$(dirname "$0")"

COMPOSE="${COMPOSE:-docker compose}"
mkdir -p identity

echo ">> bringing stack up"
$COMPOSE up -d teleport otel-collector mock-ai

echo ">> waiting for teleport to become ready"
for i in $(seq 1 60); do
  if $COMPOSE exec -T teleport tctl status >/dev/null 2>&1; then
    echo ">> teleport ready"
    break
  fi
  sleep 1
  if [[ $i -eq 60 ]]; then
    echo "!! teleport did not become ready in 60s" >&2
    $COMPOSE logs teleport | tail -40
    exit 1
  fi
done

echo ">> applying roles + users"
$COMPOSE exec -T teleport tctl create -f /etc/teleport/resources.yaml

echo ">> issuing identity files"
$COMPOSE exec -T teleport tctl auth sign \
  --user=televibe --format=file --ttl=24h --out=/identity/televibe.pem --overwrite
$COMPOSE exec -T teleport tctl auth sign \
  --user=charles  --format=file --ttl=24h --out=/identity/charles.pem  --overwrite

# tctl writes as root inside the container; make sure the host user can read.
chmod 644 identity/*.pem || true

echo
echo "all set. identity files:"
ls -1 identity/
echo
echo "next steps:"
echo "  # 1. tail metrics"
echo "  docker compose -f local/docker-compose.yml logs -f otel-collector"
echo "  # 2. start televibe from the repo root:"
echo "  go run ./cmd/televibe --config local/televibe.toml"
echo "  # 3. fire a bad request (no CUSTX, thin reason):"
echo "  go run ./cmd/submit --reason 'plz'"
echo "  # 4. fire a clean one:"
echo "  go run ./cmd/submit --reason 'CUSTX-1234 debug prod-api p95 spike, repro attached in ticket'"


Bash

Make bootstrap executable


(Bash completed with no output)


Write

# Local dev config for televibe. Points at the docker-compose stack in ./local/.

[teleport]
addr          = "localhost:3080"
identity_file = "./local/identity/televibe.pem"

[ai]
provider = "webhook"

[ai.webhook]
url = "http://localhost:8080/score"

[jira]
# Jira is stubbed out for local dev — ticket_threshold below is cranked up
# so this path is never exercised. Real values only needed in staging/prod.
url        = "http://localhost:9999"
username   = "stub"
api_token  = "stub"
project    = "ITSM"
issue_type = "Task"

[risk]
# 101 = "never file a ticket" — lets us exercise reputation scoring without
# needing a real (or mocked) Jira. Flip this back to 25 once you wire jira up.
ticket_threshold = 101
custx_pattern    = 'CUSTX-\d+'
off_hours_start  = 18
off_hours_end    = 8
timezone         = "America/New_York"

[reputation]
persist_path              = "./local/reputation.json"
starting_score            = 100
floor_score               = 0
ceiling_score             = 100
penalty_missing_custx     = 10
penalty_thin_description  = 5

[telemetry]
enabled       = true
endpoint      = "localhost:4317"
insecure      = true
service_name  = "televibe-local"
export_period = "5s"   # snappier for dev so you don't sit around waiting

[log]
level = "debug"


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/local/televibe.toml

# Local dev config for televibe. Points at the docker-compose stack in ./local/.

[teleport]
addr          = "localhost:3080"
identity_file = "./local/identity/televibe.pem"

[ai]
provider = "webhook"

[ai.webhook]
url = "http://localhost:8080/score"

[jira]
# Jira is stubbed out for local dev — ticket_threshold below is cranked up
# so this path is never exercised. Real values only needed in staging/prod.
url        = "http://localhost:9999"
username   = "stub"
api_token  = "stub"
project    = "ITSM"
issue_type = "Task"

[risk]
# 101 = "never file a ticket" — lets us exercise reputation scoring without
# needing a real (or mocked) Jira. Flip this back to 25 once you wire jira up.
ticket_threshold = 101
custx_pattern    = 'CUSTX-\d+'
off_hours_start  = 18
off_hours_end    = 8
timezone         = "America/New_York"

[reputation]
persist_path              = "./local/reputation.json"
starting_score            = 100
floor_score               = 0
ceiling_score             = 100
penalty_missing_custx     = 10
penalty_thin_description  = 5

[telemetry]
enabled       = true
endpoint      = "localhost:4317"
insecure      = true
service_name  = "televibe-local"
export_period = "5s"   # snappier for dev so you don't sit around waiting

[log]
level = "debug"


Now the submit helper.


// submit is a tiny helper for the local test harness — fires a Teleport
// access request as a given user, so you can watch televibe score it.
//
// Usage:
//
//	go run ./cmd/submit --reason "plz"
//	go run ./cmd/submit --reason "CUSTX-1234 fix prod-api latency regression"
package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"strings"
	"time"

	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
)

func main() {
	addr := flag.String("addr", "localhost:3080", "teleport proxy address")
	identity := flag.String("identity", "./local/identity/charles.pem", "identity file")
	user := flag.String("user", "charles", "requesting user")
	roles := flag.String("roles", "privileged", "comma-separated roles to request")
	reason := flag.String("reason", "", "request reason")
	flag.Parse()

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	c, err := client.New(ctx, client.Config{
		Addrs:       []string{*addr},
		Credentials: []client.Credentials{client.LoadIdentityFile(*identity)},
	})
	if err != nil {
		log.Fatalf("connect: %v", err)
	}
	defer c.Close()

	roleList := strings.Split(*roles, ",")
	req, err := types.NewAccessRequest("", *user, roleList...)
	if err != nil {
		log.Fatalf("new access request: %v", err)
	}
	req.SetRequestReason(*reason)

	created, err := c.CreateAccessRequestV2(ctx, req)
	if err != nil {
		log.Fatalf("create access request: %v", err)
	}
	fmt.Printf("submitted request %s — user=%s roles=%v reason=%q\n",
		created.GetName(), *user, roleList, *reason)
}


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/cmd/submit/main.go

// submit is a tiny helper for the local test harness — fires a Teleport
// access request as a given user, so you can watch televibe score it.
//
// Usage:
//
//	go run ./cmd/submit --reason "plz"
//	go run ./cmd/submit --reason "CUSTX-1234 fix prod-api latency regression"
package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"strings"
	"time"

	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
)

func main() {
	addr := flag.String("addr", "localhost:3080", "teleport proxy address")
	identity := flag.String("identity", "./local/identity/charles.pem", "identity file")
	user := flag.String("user", "charles", "requesting user")
	roles := flag.String("roles", "privileged", "comma-separated roles to request")
	reason := flag.String("reason", "", "request reason")
	flag.Parse()

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	c, err := client.New(ctx, client.Config{
		Addrs:       []string{*addr},
		Credentials: []client.Credentials{client.LoadIdentityFile(*identity)},
	})
	if err != nil {
		log.Fatalf("connect: %v", err)
	}
	defer c.Close()

	roleList := strings.Split(*roles, ",")
	req, err := types.NewAccessRequest("", *user, roleList...)
	if err != nil {
		log.Fatalf("new access request: %v", err)
	}
	req.SetRequestReason(*reason)

	created, err := c.CreateAccessRequestV2(ctx, req)
	if err != nil {
		log.Fatalf("create access request: %v", err)
	}
	fmt.Printf("submitted request %s — user=%s roles=%v reason=%q\n",
		created.GetName(), *user, roleList, *reason)
}


Build local docker test harness

Create local/ directory with docker-compose (teleport + otel-collector + mock-ai), teleport config, resources yaml, bootstrap script, mock-ai stub, local televibe.toml, and a cmd/submit helper to fire test requests from the host.


Bash

Verify submit cmd builds


Confirm submit builds clean


Let me also verify docker is running so I can give you actionable next steps.


docker version --format '{{.Server.Version}}' 2>&1 | head -3; docker compose version 2>&1 | head -1

Check docker availability


24.0.7
Docker Compose version v2.23.3-desktop.2


Write

# Local test harness

A docker-compose stack for exercising televibe end-to-end without real
Teleport, Anthropic, or Jira credentials.

## What runs where

| Component      | Where  | Notes                                                                |
| -------------- | ------ | -------------------------------------------------------------------- |
| Teleport       | docker | Single-node auth+proxy cluster, self-signed TLS, port `3080`         |
| OTel collector | docker | OTLP/gRPC on `4317`, prints metrics to stdout via the debug exporter |
| mock-ai        | docker | Webhook on `8080` returning a canned assessment                      |
| televibe       | host   | `go run ./cmd/televibe --config local/televibe.toml`                 |
| submit         | host   | `go run ./cmd/submit --reason "…"` fires test access requests        |

Jira is stubbed: `risk.ticket_threshold` is cranked to `101` in
`local/televibe.toml`, so nothing ever hits Jira. Reputation scoring runs
before that branch, so the test loop is intact.

## One-time setup

```sh
# from apps/televibe
./local/bootstrap.sh
```

This brings the stack up, applies the roles + users from
`local/resources.yaml`, and writes identity files to `local/identity/` for
the `televibe` plugin user and the `charles` test requester. Identities
are TTL 24h — re-run the script when they expire.

## Running

Three terminals:

```sh
# 1. watch metrics flow past
docker compose -f local/docker-compose.yml logs -f otel-collector

# 2. run televibe against the local cluster
go run ./cmd/televibe --config local/televibe.toml

# 3. fire test access requests
go run ./cmd/submit --reason "plz"
go run ./cmd/submit --reason "CUSTX-1234 debug prod-api p95 spike, repro in ticket"
```

## What you should see

- **televibe logs** — a `reputation docked` line for the "plz" request
  (no CUSTX reference, <40 chars → both penalties), no line for the
  well-formed one.
- **otel-collector logs** — every 5 seconds, a `televibe.user.reputation`
  gauge emitted for each user that has been scored, tagged with `user`.
  Charles' score should start at `100` and drop by `15` (10 + 5) per bad
  request, floored at `0`.
- **local/reputation.json** — the persisted state. Delete it to start fresh.

## Tearing down

```sh
docker compose -f local/docker-compose.yml down -v
rm -rf local/identity local/reputation.json
```

## Troubleshooting

- **`tctl status` times out during bootstrap** — bump the 60s wait in
  `bootstrap.sh`. First boot downloads the teleport image (~300MB).
- **`connect: context deadline exceeded` from submit** — teleport proxy
  not listening yet, or port 3080 collision. `lsof -i :3080` to check.
- **No metrics in the collector logs** — confirm `telemetry.enabled = true`
  in `local/televibe.toml` and that the gauge callback has users to
  observe (i.e., at least one request has been scored since startup;
  empty stores emit nothing).


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/local/README.md

# Local test harness

A docker-compose stack for exercising televibe end-to-end without real
Teleport, Anthropic, or Jira credentials.

## What runs where

| Component      | Where  | Notes                                                                |
| -------------- | ------ | -------------------------------------------------------------------- |
| Teleport       | docker | Single-node auth+proxy cluster, self-signed TLS, port `3080`         |
| OTel collector | docker | OTLP/gRPC on `4317`, prints metrics to stdout via the debug exporter |
| mock-ai        | docker | Webhook on `8080` returning a canned assessment                      |
| televibe       | host   | `go run ./cmd/televibe --config local/televibe.toml`                 |
| submit         | host   | `go run ./cmd/submit --reason "…"` fires test access requests        |

Jira is stubbed: `risk.ticket_threshold` is cranked to `101` in
`local/televibe.toml`, so nothing ever hits Jira. Reputation scoring runs
before that branch, so the test loop is intact.

## One-time setup

```sh
# from apps/televibe
./local/bootstrap.sh
```

This brings the stack up, applies the roles + users from
`local/resources.yaml`, and writes identity files to `local/identity/` for
the `televibe` plugin user and the `charles` test requester. Identities
are TTL 24h — re-run the script when they expire.

## Running

Three terminals:

```sh
# 1. watch metrics flow past
docker compose -f local/docker-compose.yml logs -f otel-collector

# 2. run televibe against the local cluster
go run ./cmd/televibe --config local/televibe.toml

# 3. fire test access requests
go run ./cmd/submit --reason "plz"
go run ./cmd/submit --reason "CUSTX-1234 debug prod-api p95 spike, repro in ticket"
```

## What you should see

- **televibe logs** — a `reputation docked` line for the "plz" request
  (no CUSTX reference, <40 chars → both penalties), no line for the
  well-formed one.
- **otel-collector logs** — every 5 seconds, a `televibe.user.reputation`
  gauge emitted for each user that has been scored, tagged with `user`.
  Charles' score should start at `100` and drop by `15` (10 + 5) per bad
  request, floored at `0`.
- **local/reputation.json** — the persisted state. Delete it to start fresh.

## Tearing down

```sh
docker compose -f local/docker-compose.yml down -v
rm -rf local/identity local/reputation.json
```

## Troubleshooting

- **`tctl status` times out during bootstrap** — bump the 60s wait in
  `bootstrap.sh`. First boot downloads the teleport image (~300MB).
- **`connect: context deadline exceeded` from submit** — teleport proxy
  not listening yet, or port 3080 collision. `lsof -i :3080` to check.
- **No metrics in the collector logs** — confirm `telemetry.enabled = true`
  in `local/televibe.toml` and that the gauge callback has users to
  observe (i.e., at least one request has been scored since startup;
  empty stores emit nothing).


Bash

docker compose -f /Users/steve.briggs/projects/operations/apps/televibe/local/docker-compose.yml config --quiet && echo "COMPOSE_OK"

Validate compose file syntax


Write

identity/
reputation.json


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/local/.gitignore

identity/
reputation.json


Harness is in place, Sir Charles. Everything under `apps/televibe/local/` plus a host-side `cmd/submit` helper.

**Layout**
```
apps/televibe/
├── local/
│   ├── docker-compose.yml       teleport + otel-collector + mock-ai
│   ├── teleport.yaml            v3 cluster config, self-signed TLS
│   ├── resources.yaml           televibe + charles users, roles
│   ├── otel-collector.yaml      OTLP/gRPC in → debug exporter (stdout)
│   ├── mock-ai/main.go          stub webhook on :8080
│   ├── bootstrap.sh             brings stack up, issues identity files
│   ├── televibe.toml            local config (threshold=101, no Jira)
│   ├── .gitignore               ignores identity/, reputation.json
│   └── README.md                the runbook
└── cmd/submit/main.go           fires test access requests from host
```

**What to run**

```sh
cd apps/televibe

# 1. one-time: stack up + identity files issued
./local/bootstrap.sh

# 2. in one terminal, watch otel output
docker compose -f local/docker-compose.yml logs -f otel-collector

# 3. in another, run televibe
go run ./cmd/televibe --config local/televibe.toml

# 4. fire a deliberately awful request
go run ./cmd/submit --reason 'plz'

# 5. fire a well-formed one
go run ./cmd/submit --reason 'CUSTX-1234 debug prod-api p95 spike, repro in ticket'
```

**What you should see**

- televibe log: `reputation docked previous=100 current=85 reasons=[missing_custx_ticket thin_description]` on the `plz` one; nothing on the clean one.
- Collector log: every 5s, a `televibe.user.reputation` gauge emitted per scored user, tagged `user=charles`. Watch it tick down 15 per bad request, floored at 0.
- `local/reputation.json` — the persisted map. `rm` it to reset.

**Notes**
- First `docker compose up` pulls the teleport image (~300MB); bootstrap waits up to 60s for readiness.
- Identity files TTL to 24h. Re-run `bootstrap.sh` when they expire — it's idempotent.
- Teleport major version pinned to `17`. If your cluster API pseudo-version in `go.mod` prefers a newer server, bump the image tag in `docker-compose.yml`.
- I did **not** stub Jira. If you decide you want to exercise the ticket-creation path too, say the word and I'll add a `mock-jira` service and drop `ticket_threshold` back to 25.


Goal: add user reputation scoring to televibe with OTel export and build a local Docker test harness. Harness is ready; next action is to run `./local/bootstrap.sh` from `apps/televibe` to bring the stack up. (disable recaps in /config)


using the local bootstrap script i got the following error; >> waiting for teleport to become ready
!! teleport did not become ready in 60s
televibe-teleport  | teleport: error: unexpected start


Classic distroless-image foot-gun, Sir Charles. The `public.ecr.aws/gravitational/teleport-distroless:17` image bakes `start` into its `ENTRYPOINT`, so our `command: ["start", ...]` ends up running `teleport start start -c ...` — which is, yes, "unexpected start".

Fix is a one-line override in the compose file.


Edit

  teleport:
    image: public.ecr.aws/gravitational/teleport-distroless:17
    container_name: televibe-teleport
    command: ["start", "-c", "/etc/teleport/teleport.yaml"]

  teleport:
    image: public.ecr.aws/gravitational/teleport-distroless:17
    container_name: televibe-teleport
    # Override the baked-in ENTRYPOINT (which includes `start`) so we can
    # pass args cleanly without double-subcommands.
    entrypoint: ["/usr/local/bin/teleport"]
    command: ["start", "-c", "/etc/teleport/teleport.yaml"]