	if a.cfg.DryRun {
		log.Info("dry-run: would create ITSM ticket and hold request pending",
			"score", result.Score,
			"tier", result.Tier.Name,
			"threshold", threshold)
		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,
		Components:  result.Components,
		AIReasoning: result.Reasoning,
		Escalation:  result.Escalation,
	})

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,
		"dry_run", a.cfg.DryRun)
	if a.cfg.DryRun {
		slog.Warn("dry-run enabled: no Teleport reviews or Jira tickets will be submitted")
	}

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

	// Run watcher/poller under their own cancelable context rather than
	// bolting them directly to the signal context. A coordinator
	// goroutine waits for the signal, then sleeps for
	// telemetry.shutdown_grace before cancelling the errgroup so that
	// the OTel periodic reader can finish an in-flight export cleanly.
	runCtx, cancelRun := context.WithCancel(context.Background())
	defer cancelRun()
	g, gctx := errgroup.WithContext(runCtx)
	g.Go(func() error { return a.watchRequests(gctx) })
	g.Go(func() error { return a.pollITSM(gctx) })
	g.Go(func() error {
		select {
		case <-ctx.Done():
			grace := time.Duration(0)
			if a.cfg.Telemetry.Enabled {
				grace = a.cfg.Telemetry.ShutdownGrace
			}
			if grace > 0 {
				slog.Info("shutdown signal received; waiting for telemetry flush", "grace", grace)
				select {
				case <-time.After(grace):
				case <-gctx.Done():
					// errgroup collapsed for some other reason; stop waiting.
				}
			}
			cancelRun()
		case <-gctx.Done():
		}
		return nil
	})

	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
			}
			if !a.reserveOrSkip(req.GetName()) {
				continue
			}
			go a.handleRequest(ctx, req)
		}
	}
}

// reserveOrSkip atomically claims a slot for request id. Returns true if
// this caller won the race and should process the request; false if the
// id is already in flight or already has a ticket. Using the same map
// for both reservations and ticket mappings keeps dedup decisions on a
// single source of truth.
func (a *App) reserveOrSkip(id string) bool {
	a.pendingMu.Lock()
	defer a.pendingMu.Unlock()
	if _, held := a.pending[id]; held {
		return false
	}
	a.pending[id] = ""
	return true
}

func (a *App) releasePending(id string) {
	a.pendingMu.Lock()
	defer a.pendingMu.Unlock()
	delete(a.pending, id)
}

func (a *App) recordTicket(id, ticket string) {
	a.pendingMu.Lock()
	defer a.pendingMu.Unlock()
	a.pending[id] = ticket
}

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

	// Release the reservation unless we successfully record a ticket;
	// that way score/Jira failures are re-tryable on the next OpPut and
	// auto-approved requests don't leave stale entries behind.
	handled := false
	defer func() {
		if !handled {
			a.releasePending(id)
		}
	}()

	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,
		"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)
	}

	threshold := a.cfg.Risk.TicketThreshold
	if result.Escalation {
		threshold = a.cfg.Risk.EscalationThresholdFor(req.GetRoles())
		log.Info("session escalation detected", "threshold", threshold)
	}

	if result.Score < threshold {
		reason := fmt.Sprintf("televibe: %s (score %d) — auto-approved",
			result.Tier.Name, result.Score)
		if err := a.submitReview(ctx, id, types.RequestState_APPROVED, reason); err != nil {
			log.Error("auto-approve failed", "err", err)
			return
		}
		log.Info("access request auto-approved",
			"score", result.Score,
			"tier", result.Tier.Name,
			"threshold", threshold)
		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,
		Components:  result.Components,
		AIReasoning: result.Reasoning,
		Escalation:  result.Escalation,
	})
	if err != nil {
		log.Error("create ITSM ticket failed", "err", err)
		return
	}

	a.recordTicket(id, ticket)
	handled = true
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name)
}

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 {
		if ticket == "" {
			// Reservation for an in-flight handleRequest; nothing to poll yet.
			continue
		}
		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.Info("dry-run: would create ITSM ticket and hold request pending",


Edit

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
}

func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
	if a.cfg.DryRun {
		slog.Info("dry-run: skipping AccessReview submission",
			"request_id", requestID,
			"state", state.String(),
			"reason", reason)
		return nil
	}
	_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
		RequestID: requestID,
		Review: types.AccessReview{
			Author:        "televibe",
			ProposedState: state,
			Reason:        reason,
		},
	})
	return err
}


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

func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
	if a.cfg.DryRun {
		slog.Info("dry-run: skipping AccessReview submission",
			"request_id", requestID,
			"state", state.String(),
			"reason", reason)
		return nil
	}
	_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
		RequestID: requestID,
		Review: types.AccessReview{
			Author:        "televibe",
			ProposedState: state,
			Reason:        reason,
		},
	})
	return err
}

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,
		"dry_run", a.cfg.DryRun)
	if a.cfg.DryRun {
		slog.Warn("dry-run enabled: no Teleport reviews or Jira tickets will be submitted")
	}

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

	// Run watcher/poller under their own cancelable context rather than
	// bolting them directly to the signal context. A coordinator
	// goroutine waits for the signal, then sleeps for
	// telemetry.shutdown_grace before cancelling the errgroup so that
	// the OTel periodic reader can finish an in-flight export cleanly.
	runCtx, cancelRun := context.WithCancel(context.Background())
	defer cancelRun()
	g, gctx := errgroup.WithContext(runCtx)
	g.Go(func() error { return a.watchRequests(gctx) })
	g.Go(func() error { return a.pollITSM(gctx) })
	g.Go(func() error {
		select {
		case <-ctx.Done():
			grace := time.Duration(0)
			if a.cfg.Telemetry.Enabled {
				grace = a.cfg.Telemetry.ShutdownGrace
			}
			if grace > 0 {
				slog.Info("shutdown signal received; waiting for telemetry flush", "grace", grace)
				select {
				case <-time.After(grace):
				case <-gctx.Done():
					// errgroup collapsed for some other reason; stop waiting.
				}
			}
			cancelRun()
		case <-gctx.Done():
		}
		return nil
	})

	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
			}
			if !a.reserveOrSkip(req.GetName()) {
				continue
			}
			go a.handleRequest(ctx, req)
		}
	}
}

// reserveOrSkip atomically claims a slot for request id. Returns true if
// this caller won the race and should process the request; false if the
// id is already in flight or already has a ticket. Using the same map
// for both reservations and ticket mappings keeps dedup decisions on a
// single source of truth.
func (a *App) reserveOrSkip(id string) bool {
	a.pendingMu.Lock()
	defer a.pendingMu.Unlock()
	if _, held := a.pending[id]; held {
		return false
	}
	a.pending[id] = ""
	return true
}

func (a *App) releasePending(id string) {
	a.pendingMu.Lock()
	defer a.pendingMu.Unlock()
	delete(a.pending, id)
}

func (a *App) recordTicket(id, ticket string) {
	a.pendingMu.Lock()
	defer a.pendingMu.Unlock()
	a.pending[id] = ticket
}

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

	// Release the reservation unless we successfully record a ticket;
	// that way score/Jira failures are re-tryable on the next OpPut and
	// auto-approved requests don't leave stale entries behind.
	handled := false
	defer func() {
		if !handled {
			a.releasePending(id)
		}
	}()

	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,
		"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)
	}

	threshold := a.cfg.Risk.TicketThreshold
	if result.Escalation {
		threshold = a.cfg.Risk.EscalationThresholdFor(req.GetRoles())
		log.Info("session escalation detected", "threshold", threshold)
	}

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

	if a.cfg.DryRun {
		log.Info("dry-run: would create ITSM ticket and hold request pending",
			"score", result.Score,
			"tier", result.Tier.Name,
			"threshold", threshold)
		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,
		Components:  result.Components,
		AIReasoning: result.Reasoning,
		Escalation:  result.Escalation,
	})
	if err != nil {
		log.Error("create ITSM ticket failed", "err", err)
		return
	}

	a.recordTicket(id, ticket)
	handled = true
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name)
}

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 {
		if ticket == "" {
			// Reservation for an in-flight handleRequest; nothing to poll yet.
			continue
		}
		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
}

+    slog.Info("dry-run: skipping AccessReview submission",


Updated task #2 status


Updated task #3 status


Can we add an option for this teleport plugin to only log what the decisions would be instead of taking action on them? A dry-run option if you will, it should be configurable via the configuration fi…


# Maps (risk.escalation_thresholds) and slices are TOML-only.

[teleport]

# Maps (risk.escalation_thresholds) and slices are TOML-only.

# When true, log the decision (auto-approve / would-create-ticket) for each
# access request without calling Teleport or Jira. Useful for previewing a
# new threshold or AI provider before flipping the switch in prod.
# dry_run = false

[teleport]


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

# Maps (risk.escalation_thresholds) and slices are TOML-only.

# When true, log the decision (auto-approve / would-create-ticket) for each
# access request without calling Teleport or Jira. Useful for previewing a
# new threshold or AI provider before flipping the switch in prod.
# dry_run = false

[teleport]

# televibe — Teleport access plugin with AI risk scoring
# Copy to /etc/televibe/televibe.toml and fill in.
#
# Any scalar key can also be overridden via a TV_-prefixed environment
# variable (e.g. TV_TELEPORT_ADDR, TV_AI_ANTHROPIC_API_KEY,
# TV_JIRA_POLL_INTERVAL=2m). Precedence: env > this file > defaults.
# Maps (risk.escalation_thresholds) and slices are TOML-only.

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

[ai]
# Exactly one of: "anthropic", "bedrock", "random".
# "random" is for local dev / integration tests only — it fabricates
# assessments and must not be used in production.
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"
# Authentication: by default the AWS SDK default credential chain is used,
# which covers IRSA, EKS Pod Identity, EC2 instance profiles, ECS task roles,
# env vars, and ~/.aws/credentials — no config needed in prod.
# profile = "dev"                           # named shared-config profile (local dev)
# role_arn = "arn:aws:iam::222222222222:role/bedrock-invoker"  # cross-account AssumeRole
# role_session_name = "televibe"
# external_id = "optional-external-id"

[ai.random]
# Optional: set a non-zero seed for deterministic output (useful in CI).
# seed = 0

[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"

# Session escalations — access requests that Teleport tags with the
# escalation_annotation (set via the role's allow.request.annotations).
# These use a tighter fallback threshold. Per-role overrides in
# [risk.escalation_thresholds] take precedence; the strictest (lowest)
# value wins when a request names multiple roles.
escalation_annotation = "televibe.escalation"
escalation_threshold  = 15

[risk.escalation_thresholds]
# "sre-admin"    = 5
# "db-admin"     = 10
# "read-only"    = 25

[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"
# Delay between receiving SIGINT/SIGTERM and cancelling the watcher so the
# OTel periodic reader can finish an in-flight export cleanly. Set to "0s"
# to shut down immediately at the cost of a noisy "context canceled"
# exporter error.
shutdown_grace = "2s"

[log]
level = "info"

+# When true, log the decision (auto-approve / would-create-ticket) for each

+# access request without calling Teleport or Jira. Useful for previewing a

+# new threshold or AI provider before flipping the switch in prod.


| `TV_TELEPORT_ADDR`               | `teleport.addr`                                |

| `TV_DRY_RUN`                     | `dry_run` (bool — log decisions without acting) |
| `TV_TELEPORT_ADDR`               | `teleport.addr`                                |


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

| `TV_DRY_RUN`                     | `dry_run` (bool — log decisions without acting) |
| `TV_TELEPORT_ADDR`               | `teleport.addr`                                |

# 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:

| Score  | Tier     | Action                                 |
| ------ | -------- | -------------------------------------- |
| 0–24   | low      | auto-approve, no Jira ticket           |
| 25–49  | medium   | file ITSM ticket, hold request pending |
| 50–74  | high     | file ITSM ticket with tier label       |
| 75–100 | critical | file ITSM ticket with tier label       |

The ticket-creation threshold is configurable (`risk.ticket_threshold`). Session escalations — access requests carrying the `televibe.escalation` Teleport annotation — use a tighter fallback threshold (`risk.escalation_threshold`) and support per-role overrides in `risk.escalation_thresholds`.

## 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 (IRSA / Pod Identity / env / profile); optional `profile` and `role_arn` for local dev and cross-account AssumeRole.
- **`random`** — test-only provider that fabricates assessments within valid ranges. Use for local dev and integration tests; emits a startup warning so it doesn't silently ship to prod. Accepts an optional `seed` for deterministic runs.

## 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.

### Environment variable overrides

Every scalar TOML key can be overridden by an environment variable. Use this to keep secrets out of the TOML file (e.g. Jira API tokens, Anthropic keys), or to vary deploy-specific values per environment.

**Naming rule:** `TV_` + the TOML section name + `_` + the field's TOML key, all uppercased. Nested sections (like `ai.anthropic`) concatenate with `_`. So `ai.anthropic.api_key` becomes `TV_AI_ANTHROPIC_API_KEY`.

**Precedence:** env var > `televibe.toml` > built-in default.

**Supported types:** `string`, `bool`, integer, float, and `time.Duration` (Go duration strings — `30s`, `2m`, `1h30m`).

**Not supported:** maps (`risk.escalation_thresholds`) and slices — TOML-only.

Common examples:

| Env var                          | Overrides                                      |
| -------------------------------- | ---------------------------------------------- |
| `TV_TELEPORT_ADDR`               | `teleport.addr`                                |
| `TV_TELEPORT_IDENTITY_FILE`      | `teleport.identity_file`                       |
| `TV_AI_PROVIDER`                 | `ai.provider`                                  |
| `TV_AI_ANTHROPIC_API_KEY`        | `ai.anthropic.api_key`                         |
| `TV_AI_ANTHROPIC_MODEL`          | `ai.anthropic.model`                           |
| `TV_AI_BEDROCK_REGION`           | `ai.bedrock.region`                            |
| `TV_AI_BEDROCK_MODEL_ID`         | `ai.bedrock.model_id`                          |
| `TV_AI_BEDROCK_PROFILE`          | `ai.bedrock.profile`                           |
| `TV_AI_BEDROCK_ROLE_ARN`         | `ai.bedrock.role_arn`                          |
| `TV_JIRA_URL`                    | `jira.url`                                     |
| `TV_JIRA_USERNAME`               | `jira.username`                                |
| `TV_JIRA_API_TOKEN`              | `jira.api_token`                               |
| `TV_JIRA_PROJECT`                | `jira.project`                                 |
| `TV_JIRA_POLL_INTERVAL`          | `jira.poll_interval` (Go duration, e.g. `2m`)  |
| `TV_RISK_TICKET_THRESHOLD`       | `risk.ticket_threshold`                        |
| `TV_RISK_ESCALATION_ANNOTATION`  | `risk.escalation_annotation`                   |
| `TV_RISK_ESCALATION_THRESHOLD`   | `risk.escalation_threshold`                    |
| `TV_REPUTATION_PERSIST_PATH`     | `reputation.persist_path`                      |
| `TV_REPUTATION_STARTING_SCORE`   | `reputation.starting_score`                    |
| `TV_TELEMETRY_ENABLED`           | `telemetry.enabled`                            |
| `TV_TELEMETRY_ENDPOINT`          | `telemetry.endpoint`                           |
| `TV_TELEMETRY_EXPORT_PERIOD`     | `telemetry.export_period` (duration)           |
| `TV_TELEMETRY_SHUTDOWN_GRACE`    | `telemetry.shutdown_grace` (duration; `0s` disables the wait) |
| `TV_LOG_LEVEL`                   | `log.level`                                    |

An invalid value (e.g. `TV_RISK_TICKET_THRESHOLD=abc`) fails startup with the offending variable name in the error, so misconfigurations surface immediately.

### 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 / random impls
├── risk/                   scorer, tier table
├── 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
```

## Restart safety

On startup, `televibe` runs a JQL search for open tickets labelled `teleport` 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.

| Env var                          | Overrides                                      |

+| `TV_DRY_RUN`                     | `dry_run` (bool — log decisions without acting) |

| `TV_AI_PROVIDER`                 | `ai.provider`                                  |


### Dry-run mode

Set `dry_run = true` (or `TV_DRY_RUN=true`) to score every incoming request and
log the decision televibe *would* make — auto-approve, or "would file ITSM
ticket and hold pending" — without actually calling Teleport or Jira. Handy for
previewing a new threshold, AI provider, or escalation rule before letting the
plugin loose on production access requests.

### Environment variable overrides


### Dry-run mode

Set `dry_run = true` (or `TV_DRY_RUN=true`) to score every incoming request and
log the decision televibe *would* make — auto-approve, or "would file ITSM
ticket and hold pending" — without actually calling Teleport or Jira. Handy for
previewing a new threshold, AI provider, or escalation rule before letting the
plugin loose on production access requests.

### Environment variable overrides

# 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:

| Score  | Tier     | Action                                 |
| ------ | -------- | -------------------------------------- |
| 0–24   | low      | auto-approve, no Jira ticket           |
| 25–49  | medium   | file ITSM ticket, hold request pending |
| 50–74  | high     | file ITSM ticket with tier label       |
| 75–100 | critical | file ITSM ticket with tier label       |

The ticket-creation threshold is configurable (`risk.ticket_threshold`). Session escalations — access requests carrying the `televibe.escalation` Teleport annotation — use a tighter fallback threshold (`risk.escalation_threshold`) and support per-role overrides in `risk.escalation_thresholds`.

## 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 (IRSA / Pod Identity / env / profile); optional `profile` and `role_arn` for local dev and cross-account AssumeRole.
- **`random`** — test-only provider that fabricates assessments within valid ranges. Use for local dev and integration tests; emits a startup warning so it doesn't silently ship to prod. Accepts an optional `seed` for deterministic runs.

## 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.

### Environment variable overrides

Every scalar TOML key can be overridden by an environment variable. Use this to keep secrets out of the TOML file (e.g. Jira API tokens, Anthropic keys), or to vary deploy-specific values per environment.

**Naming rule:** `TV_` + the TOML section name + `_` + the field's TOML key, all uppercased. Nested sections (like `ai.anthropic`) concatenate with `_`. So `ai.anthropic.api_key` becomes `TV_AI_ANTHROPIC_API_KEY`.

**Precedence:** env var > `televibe.toml` > built-in default.

**Supported types:** `string`, `bool`, integer, float, and `time.Duration` (Go duration strings — `30s`, `2m`, `1h30m`).

**Not supported:** maps (`risk.escalation_thresholds`) and slices — TOML-only.

Common examples:

| Env var                          | Overrides                                      |
| -------------------------------- | ---------------------------------------------- |
| `TV_DRY_RUN`                     | `dry_run` (bool — log decisions without acting) |
| `TV_TELEPORT_ADDR`               | `teleport.addr`                                |
| `TV_TELEPORT_IDENTITY_FILE`      | `teleport.identity_file`                       |
| `TV_AI_PROVIDER`                 | `ai.provider`                                  |
| `TV_AI_ANTHROPIC_API_KEY`        | `ai.anthropic.api_key`                         |
| `TV_AI_ANTHROPIC_MODEL`          | `ai.anthropic.model`                           |
| `TV_AI_BEDROCK_REGION`           | `ai.bedrock.region`                            |
| `TV_AI_BEDROCK_MODEL_ID`         | `ai.bedrock.model_id`                          |
| `TV_AI_BEDROCK_PROFILE`          | `ai.bedrock.profile`                           |
| `TV_AI_BEDROCK_ROLE_ARN`         | `ai.bedrock.role_arn`                          |
| `TV_JIRA_URL`                    | `jira.url`                                     |
| `TV_JIRA_USERNAME`               | `jira.username`                                |
| `TV_JIRA_API_TOKEN`              | `jira.api_token`                               |
| `TV_JIRA_PROJECT`                | `jira.project`                                 |
| `TV_JIRA_POLL_INTERVAL`          | `jira.poll_interval` (Go duration, e.g. `2m`)  |
| `TV_RISK_TICKET_THRESHOLD`       | `risk.ticket_threshold`                        |
| `TV_RISK_ESCALATION_ANNOTATION`  | `risk.escalation_annotation`                   |
| `TV_RISK_ESCALATION_THRESHOLD`   | `risk.escalation_threshold`                    |
| `TV_REPUTATION_PERSIST_PATH`     | `reputation.persist_path`                      |
| `TV_REPUTATION_STARTING_SCORE`   | `reputation.starting_score`                    |
| `TV_TELEMETRY_ENABLED`           | `telemetry.enabled`                            |
| `TV_TELEMETRY_ENDPOINT`          | `telemetry.endpoint`                           |
| `TV_TELEMETRY_EXPORT_PERIOD`     | `telemetry.export_period` (duration)           |
| `TV_TELEMETRY_SHUTDOWN_GRACE`    | `telemetry.shutdown_grace` (duration; `0s` disables the wait) |
| `TV_LOG_LEVEL`                   | `log.level`                                    |

An invalid value (e.g. `TV_RISK_TICKET_THRESHOLD=abc`) fails startup with the offending variable name in the error, so misconfigurations surface immediately.

### 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 / random impls
├── risk/                   scorer, tier table
├── 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
```

## Restart safety

On startup, `televibe` runs a JQL search for open tickets labelled `teleport` 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.

+Set `dry_run = true` (or `TV_DRY_RUN=true`) to score every incoming request and

+log the decision televibe *would* make — auto-approve, or "would file ITSM

+ticket and hold pending" — without actually calling Teleport or Jira. Handy for

+previewing a new threshold, AI provider, or escalation rule before letting the

+plugin loose on production access requests.


Updated task #4 status


	t.Setenv("TV_DRY_RUN", "true")
	t.Setenv("TV_TELEPORT_ADDR", "env-proxy.example.com:443")


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

	t.Setenv("TV_DRY_RUN", "true")
	t.Setenv("TV_TELEPORT_ADDR", "env-proxy.example.com:443")

package televibe

import (
	"testing"
	"time"

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

func TestApplyEnv_Overrides(t *testing.T) {
	t.Setenv("TV_TELEPORT_ADDR", "env-proxy.example.com:443")
	t.Setenv("TV_TELEPORT_IDENTITY_FILE", "/env/identity.pem")
	t.Setenv("TV_AI_PROVIDER", "bedrock")
	t.Setenv("TV_AI_ANTHROPIC_API_KEY", "sk-from-env")
	t.Setenv("TV_AI_BEDROCK_REGION", "us-east-2")
	t.Setenv("TV_RISK_TICKET_THRESHOLD", "42")
	t.Setenv("TV_RISK_ESCALATION_THRESHOLD", "7")
	t.Setenv("TV_JIRA_POLL_INTERVAL", "2m")
	t.Setenv("TV_TELEMETRY_ENABLED", "true")
	t.Setenv("TV_REPUTATION_STARTING_SCORE", "80")

	cfg := &Config{
		Teleport: TeleportConfig{Addr: "old:443", IdentityFile: "/old.pem"},
		AI: ai.Config{
			Provider:  "anthropic",
			Anthropic: ai.AnthropicConfig{APIKey: "sk-old"},
			Bedrock:   ai.BedrockConfig{Region: "us-west-2"},
		},
		Risk:       risk.Config{TicketThreshold: 25, EscalationThreshold: 15},
		Jira:       jira.Config{PollInterval: 30 * time.Second},
		Telemetry:  telemetry.Config{Enabled: false},
		Reputation: reputation.Config{StartingScore: 100},
	}

	if err := applyEnv(cfg); err != nil {
		t.Fatalf("applyEnv: %v", err)
	}

	checks := []struct {
		name string
		got  any
		want any
	}{
		{"teleport.addr", cfg.Teleport.Addr, "env-proxy.example.com:443"},
		{"teleport.identity_file", cfg.Teleport.IdentityFile, "/env/identity.pem"},
		{"ai.provider", cfg.AI.Provider, "bedrock"},
		{"ai.anthropic.api_key", cfg.AI.Anthropic.APIKey, "sk-from-env"},
		{"ai.bedrock.region", cfg.AI.Bedrock.Region, "us-east-2"},
		{"risk.ticket_threshold", cfg.Risk.TicketThreshold, 42},
		{"risk.escalation_threshold", cfg.Risk.EscalationThreshold, 7},
		{"jira.poll_interval", cfg.Jira.PollInterval, 2 * time.Minute},
		{"telemetry.enabled", cfg.Telemetry.Enabled, true},
		{"reputation.starting_score", cfg.Reputation.StartingScore, 80},
	}
	for _, c := range checks {
		if c.got != c.want {
			t.Errorf("%s = %v, want %v", c.name, c.got, c.want)
		}
	}
}

func TestApplyEnv_NoEnvLeavesConfigUntouched(t *testing.T) {
	cfg := &Config{
		Teleport: TeleportConfig{Addr: "untouched:443"},
		Risk:     risk.Config{TicketThreshold: 25},
	}
	if err := applyEnv(cfg); err != nil {
		t.Fatalf("applyEnv: %v", err)
	}
	if cfg.Teleport.Addr != "untouched:443" {
		t.Errorf("Addr = %q, want untouched:443", cfg.Teleport.Addr)
	}
	if cfg.Risk.TicketThreshold != 25 {
		t.Errorf("TicketThreshold = %d, want 25", cfg.Risk.TicketThreshold)
	}
}

func TestApplyEnv_InvalidValue(t *testing.T) {
	t.Setenv("TV_RISK_TICKET_THRESHOLD", "not-a-number")
	cfg := &Config{Risk: risk.Config{TicketThreshold: 25}}
	err := applyEnv(cfg)
	if err == nil {
		t.Fatal("applyEnv: expected error for non-numeric override, got nil")
	}
	if !contains(err.Error(), "TV_RISK_TICKET_THRESHOLD") {
		t.Errorf("error should name the offending env var, got: %v", err)
	}
}

func TestApplyEnv_MapFieldSkipped(t *testing.T) {
	// Maps can't be expressed as a single env var; the helper must skip
	// them silently rather than choke on an env var aimed at one.
	t.Setenv("TV_RISK_ESCALATION_THRESHOLDS", "this-would-not-parse")
	cfg := &Config{
		Risk: risk.Config{
			EscalationThresholds: map[string]int{"sre-admin": 5},
		},
	}
	if err := applyEnv(cfg); err != nil {
		t.Fatalf("applyEnv: %v", err)
	}
	if got := cfg.Risk.EscalationThresholds["sre-admin"]; got != 5 {
		t.Errorf("EscalationThresholds[sre-admin] = %d, want 5", got)
	}
}

func contains(haystack, needle string) bool {
	for i := 0; i+len(needle) <= len(haystack); i++ {
		if haystack[i:i+len(needle)] == needle {
			return true
		}
	}
	return false
}


	cfg := &Config{
		Teleport: TeleportConfig{Addr: "old:443", IdentityFile: "/old.pem"},

	cfg := &Config{
		DryRun:   false,
		Teleport: TeleportConfig{Addr: "old:443", IdentityFile: "/old.pem"},


	cfg := &Config{
		DryRun:   false,
		Teleport: TeleportConfig{Addr: "old:443", IdentityFile: "/old.pem"},

package televibe

import (
	"testing"
	"time"

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

func TestApplyEnv_Overrides(t *testing.T) {
	t.Setenv("TV_DRY_RUN", "true")
	t.Setenv("TV_TELEPORT_ADDR", "env-proxy.example.com:443")
	t.Setenv("TV_TELEPORT_IDENTITY_FILE", "/env/identity.pem")
	t.Setenv("TV_AI_PROVIDER", "bedrock")
	t.Setenv("TV_AI_ANTHROPIC_API_KEY", "sk-from-env")
	t.Setenv("TV_AI_BEDROCK_REGION", "us-east-2")
	t.Setenv("TV_RISK_TICKET_THRESHOLD", "42")
	t.Setenv("TV_RISK_ESCALATION_THRESHOLD", "7")
	t.Setenv("TV_JIRA_POLL_INTERVAL", "2m")
	t.Setenv("TV_TELEMETRY_ENABLED", "true")
	t.Setenv("TV_REPUTATION_STARTING_SCORE", "80")

	cfg := &Config{
		Teleport: TeleportConfig{Addr: "old:443", IdentityFile: "/old.pem"},
		AI: ai.Config{
			Provider:  "anthropic",
			Anthropic: ai.AnthropicConfig{APIKey: "sk-old"},
			Bedrock:   ai.BedrockConfig{Region: "us-west-2"},
		},
		Risk:       risk.Config{TicketThreshold: 25, EscalationThreshold: 15},
		Jira:       jira.Config{PollInterval: 30 * time.Second},
		Telemetry:  telemetry.Config{Enabled: false},
		Reputation: reputation.Config{StartingScore: 100},
	}

	if err := applyEnv(cfg); err != nil {
		t.Fatalf("applyEnv: %v", err)
	}

	checks := []struct {
		name string
		got  any
		want any
	}{
		{"teleport.addr", cfg.Teleport.Addr, "env-proxy.example.com:443"},
		{"teleport.identity_file", cfg.Teleport.IdentityFile, "/env/identity.pem"},
		{"ai.provider", cfg.AI.Provider, "bedrock"},
		{"ai.anthropic.api_key", cfg.AI.Anthropic.APIKey, "sk-from-env"},
		{"ai.bedrock.region", cfg.AI.Bedrock.Region, "us-east-2"},
		{"risk.ticket_threshold", cfg.Risk.TicketThreshold, 42},
		{"risk.escalation_threshold", cfg.Risk.EscalationThreshold, 7},
		{"jira.poll_interval", cfg.Jira.PollInterval, 2 * time.Minute},
		{"telemetry.enabled", cfg.Telemetry.Enabled, true},
		{"reputation.starting_score", cfg.Reputation.StartingScore, 80},
	}
	for _, c := range checks {
		if c.got != c.want {
			t.Errorf("%s = %v, want %v", c.name, c.got, c.want)
		}
	}
}

func TestApplyEnv_NoEnvLeavesConfigUntouched(t *testing.T) {
	cfg := &Config{
		Teleport: TeleportConfig{Addr: "untouched:443"},
		Risk:     risk.Config{TicketThreshold: 25},
	}
	if err := applyEnv(cfg); err != nil {
		t.Fatalf("applyEnv: %v", err)
	}
	if cfg.Teleport.Addr != "untouched:443" {
		t.Errorf("Addr = %q, want untouched:443", cfg.Teleport.Addr)
	}
	if cfg.Risk.TicketThreshold != 25 {
		t.Errorf("TicketThreshold = %d, want 25", cfg.Risk.TicketThreshold)
	}
}

func TestApplyEnv_InvalidValue(t *testing.T) {
	t.Setenv("TV_RISK_TICKET_THRESHOLD", "not-a-number")
	cfg := &Config{Risk: risk.Config{TicketThreshold: 25}}
	err := applyEnv(cfg)
	if err == nil {
		t.Fatal("applyEnv: expected error for non-numeric override, got nil")
	}
	if !contains(err.Error(), "TV_RISK_TICKET_THRESHOLD") {
		t.Errorf("error should name the offending env var, got: %v", err)
	}
}

func TestApplyEnv_MapFieldSkipped(t *testing.T) {
	// Maps can't be expressed as a single env var; the helper must skip
	// them silently rather than choke on an env var aimed at one.
	t.Setenv("TV_RISK_ESCALATION_THRESHOLDS", "this-would-not-parse")
	cfg := &Config{
		Risk: risk.Config{
			EscalationThresholds: map[string]int{"sre-admin": 5},
		},
	}
	if err := applyEnv(cfg); err != nil {
		t.Fatalf("applyEnv: %v", err)
	}
	if got := cfg.Risk.EscalationThresholds["sre-admin"]; got != 5 {
		t.Errorf("EscalationThresholds[sre-admin] = %d, want 5", got)
	}
}

func contains(haystack, needle string) bool {
	for i := 0; i+len(needle) <= len(haystack); i++ {
		if haystack[i:i+len(needle)] == needle {
			return true
		}
	}
	return false
}


		{"dry_run", cfg.DryRun, true},
		{"teleport.addr", cfg.Teleport.Addr, "env-proxy.example.com:443"},


		{"dry_run", cfg.DryRun, true},
		{"teleport.addr", cfg.Teleport.Addr, "env-proxy.example.com:443"},

package televibe

import (
	"testing"
	"time"

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

func TestApplyEnv_Overrides(t *testing.T) {
	t.Setenv("TV_DRY_RUN", "true")
	t.Setenv("TV_TELEPORT_ADDR", "env-proxy.example.com:443")
	t.Setenv("TV_TELEPORT_IDENTITY_FILE", "/env/identity.pem")
	t.Setenv("TV_AI_PROVIDER", "bedrock")
	t.Setenv("TV_AI_ANTHROPIC_API_KEY", "sk-from-env")
	t.Setenv("TV_AI_BEDROCK_REGION", "us-east-2")
	t.Setenv("TV_RISK_TICKET_THRESHOLD", "42")
	t.Setenv("TV_RISK_ESCALATION_THRESHOLD", "7")
	t.Setenv("TV_JIRA_POLL_INTERVAL", "2m")
	t.Setenv("TV_TELEMETRY_ENABLED", "true")
	t.Setenv("TV_REPUTATION_STARTING_SCORE", "80")

	cfg := &Config{
		DryRun:   false,
		Teleport: TeleportConfig{Addr: "old:443", IdentityFile: "/old.pem"},
		AI: ai.Config{
			Provider:  "anthropic",
			Anthropic: ai.AnthropicConfig{APIKey: "sk-old"},
			Bedrock:   ai.BedrockConfig{Region: "us-west-2"},
		},
		Risk:       risk.Config{TicketThreshold: 25, EscalationThreshold: 15},
		Jira:       jira.Config{PollInterval: 30 * time.Second},
		Telemetry:  telemetry.Config{Enabled: false},
		Reputation: reputation.Config{StartingScore: 100},
	}

	if err := applyEnv(cfg); err != nil {
		t.Fatalf("applyEnv: %v", err)
	}

	checks := []struct {
		name string
		got  any
		want any
	}{
		{"teleport.addr", cfg.Teleport.Addr, "env-proxy.example.com:443"},
		{"teleport.identity_file", cfg.Teleport.IdentityFile, "/env/identity.pem"},
		{"ai.provider", cfg.AI.Provider, "bedrock"},
		{"ai.anthropic.api_key", cfg.AI.Anthropic.APIKey, "sk-from-env"},
		{"ai.bedrock.region", cfg.AI.Bedrock.Region, "us-east-2"},
		{"risk.ticket_threshold", cfg.Risk.TicketThreshold, 42},
		{"risk.escalation_threshold", cfg.Risk.EscalationThreshold, 7},
		{"jira.poll_interval", cfg.Jira.PollInterval, 2 * time.Minute},
		{"telemetry.enabled", cfg.Telemetry.Enabled, true},
		{"reputation.starting_score", cfg.Reputation.StartingScore, 80},
	}
	for _, c := range checks {
		if c.got != c.want {
			t.Errorf("%s = %v, want %v", c.name, c.got, c.want)
		}
	}
}

func TestApplyEnv_NoEnvLeavesConfigUntouched(t *testing.T) {
	cfg := &Config{
		Teleport: TeleportConfig{Addr: "untouched:443"},
		Risk:     risk.Config{TicketThreshold: 25},
	}
	if err := applyEnv(cfg); err != nil {
		t.Fatalf("applyEnv: %v", err)
	}
	if cfg.Teleport.Addr != "untouched:443" {
		t.Errorf("Addr = %q, want untouched:443", cfg.Teleport.Addr)
	}
	if cfg.Risk.TicketThreshold != 25 {
		t.Errorf("TicketThreshold = %d, want 25", cfg.Risk.TicketThreshold)
	}
}

func TestApplyEnv_InvalidValue(t *testing.T) {
	t.Setenv("TV_RISK_TICKET_THRESHOLD", "not-a-number")
	cfg := &Config{Risk: risk.Config{TicketThreshold: 25}}
	err := applyEnv(cfg)
	if err == nil {
		t.Fatal("applyEnv: expected error for non-numeric override, got nil")
	}
	if !contains(err.Error(), "TV_RISK_TICKET_THRESHOLD") {
		t.Errorf("error should name the offending env var, got: %v", err)
	}
}

func TestApplyEnv_MapFieldSkipped(t *testing.T) {
	// Maps can't be expressed as a single env var; the helper must skip
	// them silently rather than choke on an env var aimed at one.
	t.Setenv("TV_RISK_ESCALATION_THRESHOLDS", "this-would-not-parse")
	cfg := &Config{
		Risk: risk.Config{
			EscalationThresholds: map[string]int{"sre-admin": 5},
		},
	}
	if err := applyEnv(cfg); err != nil {
		t.Fatalf("applyEnv: %v", err)
	}
	if got := cfg.Risk.EscalationThresholds["sre-admin"]; got != 5 {
		t.Errorf("EscalationThresholds[sre-admin] = %d, want 5", got)
	}
}

func contains(haystack, needle string) bool {
	for i := 0; i+len(needle) <= len(haystack); i++ {
		if haystack[i:i+len(needle)] == needle {
			return true
		}
	}
	return false
}


Now add a focused test for `submitReview` skipping the Teleport call when dry-run is on. Since `App.tele` is nil here, the test proves the dry-run gate fires before reaching the Teleport client.


package televibe

import (
	"sync"
	"sync/atomic"
	"testing"
)

package televibe

import (
	"context"
	"sync"
	"sync/atomic"
	"testing"

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


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

package televibe

import (
	"context"
	"sync"
	"sync/atomic"
	"testing"

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

package televibe

import (
	"sync"
	"sync/atomic"
	"testing"
)

func TestReserveOrSkip_ExactlyOneWinner(t *testing.T) {
	a := &App{pending: map[string]string{}}

	const goroutines = 100
	var start sync.WaitGroup
	var done sync.WaitGroup
	var winners atomic.Int32

	start.Add(1)
	done.Add(goroutines)
	for i := 0; i < goroutines; i++ {
		go func() {
			defer done.Done()
			start.Wait()
			if a.reserveOrSkip("req-1") {
				winners.Add(1)
			}
		}()
	}
	start.Done()
	done.Wait()

	if got := winners.Load(); got != 1 {
		t.Errorf("exactly one goroutine should reserve, got %d winners", got)
	}
	if _, ok := a.pending["req-1"]; !ok {
		t.Errorf("winning goroutine should have left a reservation in place")
	}
}

func TestReserveOrSkip_AfterReleaseReacquires(t *testing.T) {
	a := &App{pending: map[string]string{}}
	if !a.reserveOrSkip("req-1") {
		t.Fatal("first reservation should succeed")
	}
	if a.reserveOrSkip("req-1") {
		t.Fatal("second reservation should fail while held")
	}
	a.releasePending("req-1")
	if !a.reserveOrSkip("req-1") {
		t.Fatal("reservation after release should succeed (covers retry-after-failure path)")
	}
}

func TestReserveOrSkip_TicketedIDIsNotReacquired(t *testing.T) {
	a := &App{pending: map[string]string{}}
	if !a.reserveOrSkip("req-1") {
		t.Fatal("reservation should succeed")
	}
	a.recordTicket("req-1", "ITSM-42")
	if a.reserveOrSkip("req-1") {
		t.Fatal("request already holding a ticket must not be reserved again")
	}
	if got := a.pending["req-1"]; got != "ITSM-42" {
		t.Errorf("recordTicket should leave the real key in place, got %q", got)
	}
}


func TestReserveOrSkip_TicketedIDIsNotReacquired(t *testing.T) {

// In dry-run, submitReview must short-circuit before touching the Teleport
// client. With a nil client, a non-skipping path would panic, so the absence
// of a panic plus a nil error is the proof that the gate fired.
func TestSubmitReview_DryRunSkipsTeleportCall(t *testing.T) {
	a := &App{cfg: &Config{DryRun: true}, pending: map[string]string{}}
	if err := a.submitReview(context.Background(), "req-1", types.RequestState_APPROVED, "no cap"); err != nil {
		t.Fatalf("submitReview in dry-run: %v", err)
	}
}

func TestReserveOrSkip_TicketedIDIsNotReacquired(t *testing.T) {


// In dry-run, submitReview must short-circuit before touching the Teleport
// client. With a nil client, a non-skipping path would panic, so the absence
// of a panic plus a nil error is the proof that the gate fired.
func TestSubmitReview_DryRunSkipsTeleportCall(t *testing.T) {
	a := &App{cfg: &Config{DryRun: true}, pending: map[string]string{}}
	if err := a.submitReview(context.Background(), "req-1", types.RequestState_APPROVED, "no cap"); err != nil {
		t.Fatalf("submitReview in dry-run: %v", err)
	}
}

func TestReserveOrSkip_TicketedIDIsNotReacquired(t *testing.T) {

package televibe

import (
	"context"
	"sync"
	"sync/atomic"
	"testing"

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

func TestReserveOrSkip_ExactlyOneWinner(t *testing.T) {
	a := &App{pending: map[string]string{}}

	const goroutines = 100
	var start sync.WaitGroup
	var done sync.WaitGroup
	var winners atomic.Int32

	start.Add(1)
	done.Add(goroutines)
	for i := 0; i < goroutines; i++ {
		go func() {
			defer done.Done()
			start.Wait()
			if a.reserveOrSkip("req-1") {
				winners.Add(1)
			}
		}()
	}
	start.Done()
	done.Wait()

	if got := winners.Load(); got != 1 {
		t.Errorf("exactly one goroutine should reserve, got %d winners", got)
	}
	if _, ok := a.pending["req-1"]; !ok {
		t.Errorf("winning goroutine should have left a reservation in place")
	}
}

func TestReserveOrSkip_AfterReleaseReacquires(t *testing.T) {
	a := &App{pending: map[string]string{}}
	if !a.reserveOrSkip("req-1") {
		t.Fatal("first reservation should succeed")
	}
	if a.reserveOrSkip("req-1") {
		t.Fatal("second reservation should fail while held")
	}
	a.releasePending("req-1")
	if !a.reserveOrSkip("req-1") {
		t.Fatal("reservation after release should succeed (covers retry-after-failure path)")
	}
}

func TestReserveOrSkip_TicketedIDIsNotReacquired(t *testing.T) {
	a := &App{pending: map[string]string{}}
	if !a.reserveOrSkip("req-1") {
		t.Fatal("reservation should succeed")
	}
	a.recordTicket("req-1", "ITSM-42")
	if a.reserveOrSkip("req-1") {
		t.Fatal("request already holding a ticket must not be reserved again")
	}
	if got := a.pending["req-1"]; got != "ITSM-42" {
		t.Errorf("recordTicket should leave the real key in place, got %q", got)
	}
}

+// In dry-run, submitReview must short-circuit before touching the Teleport

+// client. With a nil client, a non-skipping path would panic, so the absence

+// of a panic plus a nil error is the proof that the gate fired.

+func TestSubmitReview_DryRunSkipsTeleportCall(t *testing.T) {

+  a := &App{cfg: &Config{DryRun: true}, pending: map[string]string{}}

+  if err := a.submitReview(context.Background(), "req-1", types.RequestState_APPROVED, "no cap"); err != nil {

+    t.Fatalf("submitReview in dry-run: %v", err)


Updated task #5 status


Bash

Build and run all tests with the race detector


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


Build is clean and all tests pass under `-race`.


Done, Sir Charles of St Cheese. Dry-run is wired up.

**What changed:**
- `config.go` — added top-level `DryRun bool` (`toml:"dry_run"`).
- `app.go` — `submitReview` short-circuits with an info log; `handleRequest` short-circuits before `CreateITSMTicket`; startup banner now reports `dry_run` and warns if enabled.
- `televibe.toml.example` + `README.md` — documented the option and `TV_DRY_RUN` env var.
- `env_test.go` — extended override test to cover `TV_DRY_RUN`.
- `app_test.go` — new `TestSubmitReview_DryRunSkipsTeleportCall` proving the gate fires before the nil Teleport client is dereferenced.

**Usage:**
```toml
dry_run = true
```
…or `TV_DRY_RUN=true`. Precedence is the usual env > TOML > default (`false`).

One thing worth flagging since you didn't ask: I left **reputation deductions on** in dry-run — they still mutate `reputation.json` and feed the OTel gauge. The user only asked for "decisions" to be inert and reputation isn't really a decision, but if you'd rather have a fully read-only mode for previewing on a live deployment, say the word and I'll gate `rep.Assess` and the watcher's pending writes too.


Added a dry-run option to the televibe Teleport plugin, configurable via dry_run in TOML or TV_DRY_RUN env var; build and race tests pass. Next: decide whether reputation deductions should also be skipped in dry-run. (disable recaps in /config)


lets rename the project from Televibe to RUTH


Big change — let me inventory all the places where the name appears before touching anything.


find /Users/steve.briggs/projects/operations/apps/televibe -type f \( -name "*.go" -o -name "*.toml" -o -name "*.md" -o -name "go.mod" -o -name "go.sum" -o -name "Makefile" \) | head -100

List source/config files to assess rename scope


grep -rln -i "televibe\|tv_" /Users/steve.briggs/projects/operations/apps/televibe --include="*.go" --include="*.toml" --include="*.md" --include="*.mod" --include="Makefile"

List all files containing televibe or TV_ tokens


Big rebrand — package, module, imports, env prefix, binary name, config filename, README copy, log strings. Let me read the files I haven't seen yet to scope the full edit list.


1	module televibe
2
3	go 1.25.9
4
5	require (
6		github.com/andygrunwald/go-jira/v2 v2.0.0-20250827191841-a1568d030dcc
7		github.com/aws/aws-sdk-go-v2 v1.41.6
8		github.com/aws/aws-sdk-go-v2/config v1.32.16
9		github.com/aws/aws-sdk-go-v2/credentials v1.19.15
10		github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5
11		github.com/aws/aws-sdk-go-v2/service/sts v1.42.0
12		github.com/google/uuid v1.6.0
13		github.com/gravitational/teleport/api v0.0.0-20260423131902-40e0dc19cfea
14		github.com/pelletier/go-toml/v2 v2.3.0
15		go.opentelemetry.io/otel v1.43.0
16		go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
17		go.opentelemetry.io/otel/metric v1.43.0
18		go.opentelemetry.io/otel/sdk v1.43.0
19		go.opentelemetry.io/otel/sdk/metric v1.43.0
20		golang.org/x/sync v0.20.0
21	)
22
23	require (
24		github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
25		github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
26		github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
27		github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
28		github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
29		github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
30		github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
31		github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
32		github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
33		github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
34		github.com/aws/smithy-go v1.25.0 // indirect
35		github.com/beevik/etree v1.6.0 // indirect
36		github.com/cenkalti/backoff/v5 v5.0.3 // indirect
37		github.com/cespare/xxhash/v2 v2.3.0 // indirect
38		github.com/charlievieth/strcase v0.0.5 // indirect
39		github.com/coreos/go-semver v0.3.1 // indirect
40		github.com/fatih/structs v1.1.0 // indirect
41		github.com/felixge/httpsnoop v1.0.4 // indirect
42		github.com/go-logr/logr v1.4.3 // indirect
43		github.com/go-logr/stdr v1.2.2 // indirect
44		github.com/go-piv/piv-go/v2 v2.5.0 // indirect
45		github.com/gobwas/httphead v0.1.0 // indirect
46		github.com/gobwas/pool v0.2.1 // indirect
47		github.com/gobwas/ws v1.4.0 // indirect
48		github.com/gogo/protobuf v1.3.2 // indirect
49		github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
50		github.com/google/go-querystring v1.2.0 // indirect
51		github.com/gravitational/trace v1.5.3 // indirect
52		github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
53		github.com/jonboulle/clockwork v0.5.0 // indirect
54		github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
55		github.com/russellhaering/gosaml2 v0.11.0 // indirect
56		github.com/russellhaering/goxmldsig v1.6.0 // indirect
57		github.com/trivago/tgo v1.0.7 // indirect
58		go.opentelemetry.io/auto/sdk v1.2.1 // indirect
59		go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
60		go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
61		go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
62		go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
63		go.opentelemetry.io/otel/trace v1.43.0 // indirect
64		go.opentelemetry.io/proto/otlp v1.10.0 // indirect
65		golang.org/x/crypto v0.49.0 // indirect
66		golang.org/x/net v0.52.0 // indirect
67		golang.org/x/sys v0.42.0 // indirect
68		golang.org/x/term v0.41.0 // indirect
69		golang.org/x/text v0.35.0 // indirect
70		google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
71		google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
72		google.golang.org/grpc v1.80.0 // indirect
73		google.golang.org/protobuf v1.36.11 // indirect
74		gopkg.in/yaml.v2 v2.4.0 // indirect
75	)
76

module televibe

go 1.25.9

require (
	github.com/andygrunwald/go-jira/v2 v2.0.0-20250827191841-a1568d030dcc
	github.com/aws/aws-sdk-go-v2 v1.41.6
	github.com/aws/aws-sdk-go-v2/config v1.32.16
	github.com/aws/aws-sdk-go-v2/credentials v1.19.15
	github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5
	github.com/aws/aws-sdk-go-v2/service/sts v1.42.0
	github.com/google/uuid v1.6.0
	github.com/gravitational/teleport/api v0.0.0-20260423131902-40e0dc19cfea
	github.com/pelletier/go-toml/v2 v2.3.0
	go.opentelemetry.io/otel v1.43.0
	go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
	go.opentelemetry.io/otel/metric v1.43.0
	go.opentelemetry.io/otel/sdk v1.43.0
	go.opentelemetry.io/otel/sdk/metric v1.43.0
	golang.org/x/sync v0.20.0
)

require (
	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
	github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
	github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
	github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
	github.com/aws/smithy-go v1.25.0 // indirect
	github.com/beevik/etree v1.6.0 // indirect
	github.com/cenkalti/backoff/v5 v5.0.3 // indirect
	github.com/cespare/xxhash/v2 v2.3.0 // indirect
	github.com/charlievieth/strcase v0.0.5 // indirect
	github.com/coreos/go-semver v0.3.1 // indirect
	github.com/fatih/structs v1.1.0 // indirect
	github.com/felixge/httpsnoop v1.0.4 // indirect
	github.com/go-logr/logr v1.4.3 // indirect
	github.com/go-logr/stdr v1.2.2 // indirect
	github.com/go-piv/piv-go/v2 v2.5.0 // indirect
	github.com/gobwas/httphead v0.1.0 // indirect
	github.com/gobwas/pool v0.2.1 // indirect
	github.com/gobwas/ws v1.4.0 // indirect
	github.com/gogo/protobuf v1.3.2 // indirect
	github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
	github.com/google/go-querystring v1.2.0 // indirect
	github.com/gravitational/trace v1.5.3 // indirect
	github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
	github.com/jonboulle/clockwork v0.5.0 // indirect
	github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
	github.com/russellhaering/gosaml2 v0.11.0 // indirect
	github.com/russellhaering/goxmldsig v1.6.0 // indirect
	github.com/trivago/tgo v1.0.7 // indirect
	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
	go.opentelemetry.io/otel/trace v1.43.0 // indirect
	go.opentelemetry.io/proto/otlp v1.10.0 // indirect
	golang.org/x/crypto v0.49.0 // indirect
	golang.org/x/net v0.52.0 // indirect
	golang.org/x/sys v0.42.0 // indirect
	golang.org/x/term v0.41.0 // indirect
	golang.org/x/text v0.35.0 // indirect
	google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
	google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
	google.golang.org/grpc v1.80.0 // indirect
	google.golang.org/protobuf v1.36.11 // indirect
	gopkg.in/yaml.v2 v2.4.0 // indirect
)


1	package main
2
3	import (
4		"context"
5		"errors"
6		"flag"
7		"log/slog"
8		"os"
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
26		setupLogging(cfg.Log.Level)
27
28		ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
29		defer cancel()
30
31		app, err := televibe.NewApp(ctx, cfg)
32		if err != nil {
33			slog.Error("app init failed", "err", err)
34			os.Exit(1)
35		}
36
37		runErr := app.Run(ctx)
38
39		shutdownCtx, cancelShutdown := context.WithTimeout(context.Background(), 5*time.Second)
40		defer cancelShutdown()
41		if err := app.Close(shutdownCtx); err != nil {
42			slog.Warn("shutdown error", "err", err)
43		}
44
45		if runErr != nil && !errors.Is(runErr, context.Canceled) {
46			slog.Error("app exited with error", "err", runErr)
47			os.Exit(1)
48		}
49	}
50
51	func setupLogging(level string) {
52		var l slog.Level
53		switch level {
54		case "debug":
55			l = slog.LevelDebug
56		case "warn":
57			l = slog.LevelWarn
58		case "error":
59			l = slog.LevelError
60		default:
61			l = slog.LevelInfo
62		}
63		h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: l})
64		slog.SetDefault(slog.New(h))
65	}
66

package main

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

	"televibe"
)

func main() {
	configPath := flag.String("config", "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)
	}

	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)
	}
}

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))
}


1	// submit is a tiny helper for the local test harness — fires a Teleport
2	// access request as a given user, so you can watch televibe score it.
3	//
4	// Usage:
5	//
6	//	go run ./cmd/submit --reason "plz"
7	//	go run ./cmd/submit --reason "CUSTX-1234 fix prod-api latency regression"
8	package main
9
10	import (
11		"context"
12		"flag"
13		"fmt"
14		"log"
15		"strings"
16		"time"
17
18		"github.com/google/uuid"
19		"github.com/gravitational/teleport/api/client"
20		"github.com/gravitational/teleport/api/types"
21	)
22
23	func main() {
24		addr := flag.String("addr", "localhost:3025", "teleport proxy address")
25		identity := flag.String("identity", "./local/identity/charles.pem", "identity file")
26		user := flag.String("user", "charles", "requesting user")
27		roles := flag.String("roles", "privileged", "comma-separated roles to request")
28		reason := flag.String("reason", "", "request reason")
29		flag.Parse()
30
31		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
32		defer cancel()
33
34		c, err := client.New(ctx, client.Config{
35			Addrs:                    []string{*addr},
36			Credentials:              []client.Credentials{client.LoadIdentityFile(*identity)},
37			InsecureAddressDiscovery: true,
38		})
39		if err != nil {
40			log.Fatalf("connect: %v", err)
41		}
42		defer c.Close()
43
44		roleList := strings.Split(*roles, ",")
45		req, err := types.NewAccessRequest(uuid.NewString(), *user, roleList...)
46		if err != nil {
47			log.Fatalf("new access request: %v", err)
48		}
49		req.SetRequestReason(*reason)
50
51		created, err := c.CreateAccessRequestV2(ctx, req)
52		if err != nil {
53			log.Fatalf("create access request: %v", err)
54		}
55		fmt.Printf("submitted request %s — user=%s roles=%v reason=%q\n",
56			created.GetName(), *user, roleList, *reason)
57	}
58

// 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/google/uuid"
	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
)

func main() {
	addr := flag.String("addr", "localhost:3025", "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)},
		InsecureAddressDiscovery: true,
	})
	if err != nil {
		log.Fatalf("connect: %v", err)
	}
	defer c.Close()

	roleList := strings.Split(*roles, ",")
	req, err := types.NewAccessRequest(uuid.NewString(), *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)
}


Spot-check non-trivial references in the remaining files


/Users/steve.briggs/projects/operations/apps/televibe/telemetry/telemetry.go:38:		c.ServiceName = "televibe"
/Users/steve.briggs/projects/operations/apps/televibe/telemetry/telemetry.go:83:	meter := provider.Meter("televibe/reputation")
/Users/steve.briggs/projects/operations/apps/televibe/telemetry/telemetry.go:85:		"televibe.user.reputation",
/Users/steve.briggs/projects/operations/apps/televibe/risk/risk.go:34:		c.EscalationAnnotation = "televibe.escalation"
/Users/steve.briggs/projects/operations/apps/televibe/risk/score.go:10:	"televibe/ai"
/Users/steve.briggs/projects/operations/apps/televibe/local/televibe.toml:1:# Local dev config for televibe. Points at the docker-compose stack in ./local/.
/Users/steve.briggs/projects/operations/apps/televibe/local/televibe.toml:7:identity_file = "./local/identity/televibe.pem"
/Users/steve.briggs/projects/operations/apps/televibe/local/televibe.toml:46:service_name  = "televibe-local"
/Users/steve.briggs/projects/operations/apps/televibe/local/README.md:3:A docker-compose stack for exercising televibe end-to-end without real
/Users/steve.briggs/projects/operations/apps/televibe/local/README.md:12:| AI scoring     | inproc | `random` provider in `local/televibe.toml` fabricates assessments        |
/Users/steve.briggs/projects/operations/apps/televibe/local/README.md:13:| televibe       | host   | `go run ./cmd/televibe --config local/televibe.toml`                     |
/Users/steve.briggs/projects/operations/apps/televibe/local/README.md:17:`local/televibe.toml`, so nothing ever hits Jira. Reputation scoring runs
/Users/steve.briggs/projects/operations/apps/televibe/local/README.md:23:# from apps/televibe
/Users/steve.briggs/projects/operations/apps/televibe/local/README.md:29:the `televibe` plugin user and the `charles` test requester. Identities
/Users/steve.briggs/projects/operations/apps/televibe/local/README.md:40:# 2. run televibe against the local cluster
/Users/steve.briggs/projects/operations/apps/televibe/local/README.md:41:go run ./cmd/televibe --config local/televibe.toml
/Users/steve.briggs/projects/operations/apps/televibe/local/README.md:50:- **televibe logs** — a `reputation docked` line for the "plz" request
/Users/steve.briggs/projects/operations/apps/televibe/local/README.md:53:- **otel-collector logs** — every 5 seconds, a `televibe.user.reputation`
/Users/steve.briggs/projects/operations/apps/televibe/local/README.md:73:  in `local/televibe.toml` and that the gauge callback has users to
/Users/steve.briggs/projects/operations/apps/televibe/risk/score_test.go:28:		{name: "empty key", key: "", annotations: map[string][]string{"televibe.escalation": {"true"}}, want: false},
/Users/steve.briggs/projects/operations/apps/televibe/risk/score_test.go:29:		{name: "no annotation", key: "televibe.escalation", annotations: nil, want: false},
/Users/steve.briggs/projects/operations/apps/televibe/risk/score_test.go:30:		{name: "key present, empty value", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {""}}, want: false},
/Users/steve.briggs/projects/operations/apps/televibe/risk/score_test.go:31:		{name: "key present with true", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {"true"}}, want: true},
/Users/steve.briggs/projects/operations/apps/televibe/risk/score_test.go:32:		{name: "key present with arbitrary non-empty", key: "televibe.escalation", annotations: map[string][]string{"televibe.escalation": {"yes-please"}}, want: true},
/Users/steve.briggs/projects/operations/apps/televibe/risk/score_test.go:33:		{name: "unrelated key set", key: "televibe.escalation", annotations: map[string][]string{"other": {"true"}}, want: false},
/Users/steve.briggs/projects/operations/apps/televibe/televibe.toml:1:# televibe — Teleport access plugin with AI risk scoring
/Users/steve.briggs/projects/operations/apps/televibe/televibe.toml:2:# Copy to /etc/televibe/televibe.toml and fill in.
/Users/steve.briggs/projects/operations/apps/televibe/televibe.toml:6:identity_file = "/var/lib/teleport/televibe.pem"
/Users/steve.briggs/projects/operations/apps/televibe/televibe.toml:24:# X-Tenant = "televibe"
/Users/steve.briggs/projects/operations/apps/televibe/televibe.toml:51:persist_path              = "/var/lib/televibe/reputation.json"
/Users/steve.briggs/projects/operations/apps/televibe/televibe.toml:64:service_name  = "televibe"


Show all televibe/TV_ references in remaining go files


/Users/steve.briggs/projects/operations/apps/televibe/config_test.go:1:package televibe
/Users/steve.briggs/projects/operations/apps/televibe/config_test.go:12:	path := filepath.Join(dir, "televibe.toml")
/Users/steve.briggs/projects/operations/apps/televibe/config_test.go:27:identity_file = "/tmp/televibe.pem"
/Users/steve.briggs/projects/operations/apps/televibe/config_test.go:75:identity_file = "/tmp/televibe.pem"
/Users/steve.briggs/projects/operations/apps/televibe/config_test.go:115:	t.Setenv("TV_RISK_TICKET_THRESHOLD", "42")
/Users/steve.briggs/projects/operations/apps/televibe/config_test.go:119:identity_file = "/tmp/televibe.pem"
/Users/steve.briggs/projects/operations/apps/televibe/config.go:1:package televibe
/Users/steve.briggs/projects/operations/apps/televibe/config.go:9:	"televibe/ai"
/Users/steve.briggs/projects/operations/apps/televibe/config.go:10:	"televibe/jira"
/Users/steve.briggs/projects/operations/apps/televibe/config.go:11:	"televibe/reputation"
/Users/steve.briggs/projects/operations/apps/televibe/config.go:12:	"televibe/risk"
/Users/steve.briggs/projects/operations/apps/televibe/config.go:13:	"televibe/telemetry"
/Users/steve.briggs/projects/operations/apps/televibe/config.go:17:	// DryRun, when true, logs the decision televibe would make for each
/Users/steve.briggs/projects/operations/apps/televibe/app_test.go:1:package televibe
/Users/steve.briggs/projects/operations/apps/televibe/app.go:1:package televibe
/Users/steve.briggs/projects/operations/apps/televibe/app.go:14:	"televibe/ai"
/Users/steve.briggs/projects/operations/apps/televibe/app.go:15:	"televibe/jira"
/Users/steve.briggs/projects/operations/apps/televibe/app.go:16:	"televibe/reputation"
/Users/steve.briggs/projects/operations/apps/televibe/app.go:17:	"televibe/risk"
/Users/steve.briggs/projects/operations/apps/televibe/app.go:18:	"televibe/telemetry"
/Users/steve.briggs/projects/operations/apps/televibe/app.go:92:	slog.Info("televibe starting",
/Users/steve.briggs/projects/operations/apps/televibe/app.go:262:		reason := fmt.Sprintf("televibe: %s (score %d) — auto-approved",
/Users/steve.briggs/projects/operations/apps/televibe/app.go:345:		reason := fmt.Sprintf("televibe: ITSM %s resolved (%s)", ticket, status)
/Users/steve.briggs/projects/operations/apps/televibe/app.go:367:			Author:        "televibe",
/Users/steve.briggs/projects/operations/apps/televibe/env.go:1:package televibe
/Users/steve.briggs/projects/operations/apps/televibe/env.go:14:// overridden by the env var TV_BAR_FOO. Maps and slices are intentionally
/Users/steve.briggs/projects/operations/apps/televibe/env.go:16:const envPrefix = "TV_"
/Users/steve.briggs/projects/operations/apps/televibe/env.go:20:// applyEnv overlays values from TV_-prefixed environment variables onto
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:1:package televibe
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:7:	"televibe/ai"
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:8:	"televibe/jira"
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:9:	"televibe/reputation"
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:10:	"televibe/risk"
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:11:	"televibe/telemetry"
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:15:	t.Setenv("TV_DRY_RUN", "true")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:16:	t.Setenv("TV_TELEPORT_ADDR", "env-proxy.example.com:443")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:17:	t.Setenv("TV_TELEPORT_IDENTITY_FILE", "/env/identity.pem")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:18:	t.Setenv("TV_AI_PROVIDER", "bedrock")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:19:	t.Setenv("TV_AI_ANTHROPIC_API_KEY", "sk-from-env")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:20:	t.Setenv("TV_AI_BEDROCK_REGION", "us-east-2")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:21:	t.Setenv("TV_RISK_TICKET_THRESHOLD", "42")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:22:	t.Setenv("TV_RISK_ESCALATION_THRESHOLD", "7")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:23:	t.Setenv("TV_JIRA_POLL_INTERVAL", "2m")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:24:	t.Setenv("TV_TELEMETRY_ENABLED", "true")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:25:	t.Setenv("TV_REPUTATION_STARTING_SCORE", "80")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:86:	t.Setenv("TV_RISK_TICKET_THRESHOLD", "not-a-number")
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:92:	if !contains(err.Error(), "TV_RISK_TICKET_THRESHOLD") {
/Users/steve.briggs/projects/operations/apps/televibe/env_test.go:100:	t.Setenv("TV_RISK_ESCALATION_THRESHOLDS", "this-would-not-parse")


TaskCreate

Update go.mod module path from televibe to ruth

Rename Go module


Task #6 created successfully: Rename Go module

Rename Go module


Update package declarations and imports in all .go files (televibe -> ruth)

Rename package and imports


Task #7 created successfully: Rename package and imports

Rename package and imports


Update string identifiers: env prefix TV_ -> RUTH_, AccessReview Author, telemetry service/meter/gauge names, escalation annotation default

Rename string identifiers and env prefix


Task #8 created successfully: Rename string identifiers and env prefix


Rename binary dir cmd/televibe -> cmd/ruth and config files (televibe.toml*, local/televibe.toml) using git mv

Rename binary dir and config files


Task #9 created successfully: Rename binary dir and config files

Rename binary dir and config files


Update README.md, local/README.md, and TOML comments to reflect new name

Update docs and TOML comments


Task #10 created successfully: Update docs and TOML comments

Update docs and TOML comments


Run go build ./... and go test -race ./... to verify the rename is consistent

Build and test post-rename


Task #11 created successfully: Build and test post-rename

Build and test post-rename


Quick gut-check on naming choices before I touch anything: Go conventions push toward lowercase package/module/binary. Env vars convention is uppercase. I'll go with:

- module/package/binary/config: `ruth` (lowercase)
- env prefix: `RUTH_` (was `TV_`) — **breaking change for any deployment**
- prose / branding (README headings): `RUTH` (matches how you wrote it)
- machine identifiers (Teleport audit Author, telemetry service_name, escalation annotation default `televibe.escalation` → `ruth.escalation`): lowercase `ruth`. Note: changing `escalation_annotation` default means any role config keying off `televibe.escalation` keeps working only if the user also updates their TOML — defaults shift to the new name.

Going to bake in those choices unless you say otherwise. Starting with go.mod and Go source.


Updated task #6 status


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

module televibe

go 1.25.9

require (
	github.com/andygrunwald/go-jira/v2 v2.0.0-20250827191841-a1568d030dcc
	github.com/aws/aws-sdk-go-v2 v1.41.6
	github.com/aws/aws-sdk-go-v2/config v1.32.16
	github.com/aws/aws-sdk-go-v2/credentials v1.19.15
	github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5
	github.com/aws/aws-sdk-go-v2/service/sts v1.42.0
	github.com/google/uuid v1.6.0
	github.com/gravitational/teleport/api v0.0.0-20260423131902-40e0dc19cfea
	github.com/pelletier/go-toml/v2 v2.3.0
	go.opentelemetry.io/otel v1.43.0
	go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
	go.opentelemetry.io/otel/metric v1.43.0
	go.opentelemetry.io/otel/sdk v1.43.0
	go.opentelemetry.io/otel/sdk/metric v1.43.0
	golang.org/x/sync v0.20.0
)

require (
	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
	github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
	github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
	github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
	github.com/aws/smithy-go v1.25.0 // indirect
	github.com/beevik/etree v1.6.0 // indirect
	github.com/cenkalti/backoff/v5 v5.0.3 // indirect
	github.com/cespare/xxhash/v2 v2.3.0 // indirect
	github.com/charlievieth/strcase v0.0.5 // indirect
	github.com/coreos/go-semver v0.3.1 // indirect
	github.com/fatih/structs v1.1.0 // indirect
	github.com/felixge/httpsnoop v1.0.4 // indirect
	github.com/go-logr/logr v1.4.3 // indirect
	github.com/go-logr/stdr v1.2.2 // indirect
	github.com/go-piv/piv-go/v2 v2.5.0 // indirect
	github.com/gobwas/httphead v0.1.0 // indirect
	github.com/gobwas/pool v0.2.1 // indirect
	github.com/gobwas/ws v1.4.0 // indirect
	github.com/gogo/protobuf v1.3.2 // indirect
	github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
	github.com/google/go-querystring v1.2.0 // indirect
	github.com/gravitational/trace v1.5.3 // indirect
	github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
	github.com/jonboulle/clockwork v0.5.0 // indirect
	github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
	github.com/russellhaering/gosaml2 v0.11.0 // indirect
	github.com/russellhaering/goxmldsig v1.6.0 // indirect
	github.com/trivago/tgo v1.0.7 // indirect
	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
	go.opentelemetry.io/otel/trace v1.43.0 // indirect
	go.opentelemetry.io/proto/otlp v1.10.0 // indirect
	golang.org/x/crypto v0.49.0 // indirect
	golang.org/x/net v0.52.0 // indirect
	golang.org/x/sys v0.42.0 // indirect
	golang.org/x/term v0.41.0 // indirect
	golang.org/x/text v0.35.0 // indirect
	google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
	google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
	google.golang.org/grpc v1.80.0 // indirect
	google.golang.org/protobuf v1.36.11 // indirect
	gopkg.in/yaml.v2 v2.4.0 // indirect
)


Updated task #7 status


Now rewrite each Go file's `package televibe` and `televibe/...` imports. I'll use `replace_all` per file.


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

package televibe

import (
	"fmt"
	"os"

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

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

type Config struct {
	// DryRun, when true, logs the decision televibe would make for each
	// access request without calling Teleport or Jira. Useful for previewing
	// behavior on a new threshold/config before going live.
	DryRun     bool              `toml:"dry_run"`
	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)
	}
	// Defaults go first so that TOML and env can override them — including
	// with explicit zero values. The reverse order conflates "unset" with
	// "set to 0", silently resurrecting defaults for fields a user meant
	// to disable (e.g. reputation.penalty_missing_custx = 0).
	cfg := &Config{}
	cfg.applyDefaults()
	if err := toml.Unmarshal(data, cfg); err != nil {
		return nil, fmt.Errorf("parse config: %w", err)
	}
	if err := applyEnv(cfg); err != nil {
		return nil, fmt.Errorf("apply env overrides: %w", err)
	}
	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)
	}
	if err := c.Reputation.Validate(); err != nil {
		return fmt.Errorf("reputation: %w", err)
	}
	return nil
}

-package televibe
+package ruth
import (
"fmt"

-  // DryRun, when true, logs the decision televibe would make for each

+  // DryRun, when true, logs the decision ruth would make for each

DryRun     bool              `toml:"dry_run"`