### Environment variable overrides

Any scalar TOML key can be overridden by an env var named `TV_<SECTION>_<KEY>` (uppercased). Precedence: env > TOML > defaults. Examples:

| Env var                          | Overrides                |
| -------------------------------- | ------------------------ |
| `TV_TELEPORT_ADDR`               | `teleport.addr`          |
| `TV_AI_ANTHROPIC_API_KEY`        | `ai.anthropic.api_key`   |
| `TV_AI_BEDROCK_ROLE_ARN`         | `ai.bedrock.role_arn`    |
| `TV_JIRA_POLL_INTERVAL`          | `jira.poll_interval` (Go duration, e.g. `2m`) |
| `TV_RISK_ESCALATION_THRESHOLD`   | `risk.escalation_threshold` |
| `TV_TELEMETRY_ENABLED`           | `telemetry.enabled`      |

Maps (e.g. `risk.escalation_thresholds`) and slices are TOML-only; env vars aimed at them are ignored.

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


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

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

# 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

Any scalar TOML key can be overridden by an env var named `TV_<SECTION>_<KEY>` (uppercased). Precedence: env > TOML > defaults. Examples:

| Env var                          | Overrides                |
| -------------------------------- | ------------------------ |
| `TV_TELEPORT_ADDR`               | `teleport.addr`          |
| `TV_AI_ANTHROPIC_API_KEY`        | `ai.anthropic.api_key`   |
| `TV_AI_BEDROCK_ROLE_ARN`         | `ai.bedrock.role_arn`    |
| `TV_JIRA_POLL_INTERVAL`          | `jira.poll_interval` (Go duration, e.g. `2m`) |
| `TV_RISK_ESCALATION_THRESHOLD`   | `risk.escalation_threshold` |
| `TV_TELEMETRY_ENABLED`           | `telemetry.enabled`      |

Maps (e.g. `risk.escalation_thresholds`) and slices are TOML-only; env vars aimed at them are ignored.

### 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 `televibe` in the ITSM project and pulls the Teleport request ID out of each ticket description to rebuild the held-request map. Restarts don't duplicate tickets.

## License

TBD.

-Any scalar TOML key can be overridden by an env var named `TV_<SECTION>_<KEY>` (uppercased). Precedence: env > TOML > defaults. Examples:

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

-| Env var                          | Overrides                |

-| `TV_TELEPORT_ADDR`               | `teleport.addr`          |

-| `TV_AI_ANTHROPIC_API_KEY`        | `ai.anthropic.api_key`   |

-| `TV_AI_BEDROCK_ROLE_ARN`         | `ai.bedrock.role_arn`    |

-| `TV_JIRA_POLL_INTERVAL`          | `jira.poll_interval` (Go duration, e.g. `2m`) |

-| `TV_RISK_ESCALATION_THRESHOLD`   | `risk.escalation_threshold` |

-| `TV_TELEMETRY_ENABLED`           | `telemetry.enabled`      |

+**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`.

-Maps (e.g. `risk.escalation_thresholds`) and slices are TOML-only; env vars aimed at them are ignored.

+**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.

+| 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_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.


can you update the readme to remove the slang bits and also document the optional env var configuration options?


Edit

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

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.


# 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_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 `televibe` in the ITSM project and pulls the Teleport request ID out of each ticket description to rebuild the held-request map. Restarts don't duplicate tickets.

## License

TBD.

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

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


Bash

grep -nE "slang|no cap|\"sus\"|mad sus|straight L|touch grass|labelled televibe" /Users/steve.briggs/projects/operations/apps/televibe/README.md 2>&1

Verify no slang or stale label references remain in README


(Bash completed with no output)


Clean — no slang or stale label references left in the README.

Summary:

**Slang removal**
- Dropped the "audit logs should spark joy" intro line.
- Tier table now has three columns (Score / Tier / Action) instead of four; the `Slang` column is gone.
- Added a follow-on sentence mentioning the escalation threshold + per-role overrides since that's useful context the old doc didn't have.

**Env var documentation expanded**
- Kept the section right under `## Configure` but fleshed it out with:
  - Explicit **naming rule** (how TOML keys map to env var names).
  - **Precedence** statement (env > TOML > defaults).
  - **Supported types** list, calling out `time.Duration` so operators know to use Go duration strings.
  - **Not supported** note covering maps and slices.
  - A 23-row mapping table covering every section (teleport, ai.anthropic, ai.bedrock, jira, risk, reputation, telemetry, log) so an operator can grep for the field they care about.
  - A closing note that invalid values fail startup with the variable name in the error.

**Incidental fix**
- The restart-safety paragraph still said tickets are labelled `televibe`; updated to `teleport` to match the label we actually set a couple turns ago. Would have been a puzzler for the next operator.


We're extending the televibe Teleport plugin with session-escalation handling, Bedrock auth options, a test-only random AI provider, TV_ env var overrides, and doc cleanup. All changes are built, tested, and complete; next action is yours — commit, push, or pick a new task.


Cursor bugbot said this on the PR; Race allows duplicate processing of same request

High Severity

The a.pending map is only populated after the full handleRequest completes (AI scoring, reputation, Jira ticket creation), but the dedup check happens before launching the goroutine. If the Teleport watcher delivers another event for the same request during that window, a second goroutine passes the held check and processes it concurrently, creating duplicate ITSM tickets or duplicate auto-approvals.
Additional Locations (1)

    apps/televibe/app.go#L214-L217


120
121	func (a *App) watchRequests(ctx context.Context) error {
122		watcher, err := a.tele.NewWatcher(ctx, types.Watch{
123			Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}},
124		})
125		if err != nil {
126			return fmt.Errorf("start watcher: %w", err)
127		}
128		defer watcher.Close()
129
130		for {
131			select {
132			case <-ctx.Done():
133				return ctx.Err()
134			case <-watcher.Done():
135				return watcher.Error()
136			case ev := <-watcher.Events():
137				if ev.Type != types.OpPut {
138					continue
139				}
140				req, ok := ev.Resource.(types.AccessRequest)
141				if !ok {
142					continue
143				}
144				if req.GetState() != types.RequestState_PENDING {
145					continue
146				}
147				a.pendingMu.Lock()
148				_, held := a.pending[req.GetName()]
149				a.pendingMu.Unlock()
150				if held {
151					continue
152				}
153				go a.handleRequest(ctx, req)
154			}
155		}
156	}
157
158	func (a *App) handleRequest(ctx context.Context, req types.AccessRequest) {
159		id := req.GetName()
160		user := req.GetUser()
161		log := slog.With("request_id", id, "user", user)
162
163		log.Info("scoring access request")
164		result, err := a.scorer.Score(ctx, a.ai, req)
165		if err != nil {
166			log.Error("scoring failed", "err", err)
167			return
168		}
169		log.Info("risk scored",
170			"score", result.Score,
171			"tier", result.Tier.Name,
172			"components", result.Components)
173
174		assessment, err := a.rep.Assess(ctx, user, result.Components)
175		if err != nil {
176			log.Warn("reputation update failed", "err", err)
177		}
178		if len(assessment.Penalties) > 0 {
179			reasons := make([]string, 0, len(assessment.Penalties))
180			for _, p := range assessment.Penalties {
181				reasons = append(reasons, p.Reason)
182			}
183			log.Info("reputation docked",
184				"previous", assessment.Previous,
185				"current", assessment.Score,
186				"reasons", reasons)
187		}
188
189		threshold := a.cfg.Risk.TicketThreshold
190		if result.Escalation {
191			threshold = a.cfg.Risk.EscalationThresholdFor(req.GetRoles())
192			log.Info("session escalation detected", "threshold", threshold)
193		}
194
195		if result.Score < threshold {
196			reason := fmt.Sprintf("televibe: %s (score %d) — auto-approved",
197				result.Tier.Name, result.Score)
198			if err := a.submitReview(ctx, id, types.RequestState_APPROVED, reason); err != nil {
199				log.Error("auto-approve failed", "err", err)
200			}
201			return
202		}
203
204		ticket, err := a.jira.CreateITSMTicket(ctx, jira.TicketInput{
205			RequestID:   id,
206			User:        req.GetUser(),
207			Roles:       req.GetRoles(),
208			Reason:      req.GetRequestReason(),
209			Score:       result.Score,
210			Tier:        result.Tier.Name,
211			Components:  result.Components,
212			AIReasoning: result.Reasoning,
213			Escalation:  result.Escalation,
214		})
215		if err != nil {
216			log.Error("create ITSM ticket failed", "err", err)
217
218			return
219		}
220
221		a.pendingMu.Lock()
222		a.pending[id] = ticket
223		a.pendingMu.Unlock()
224		log.Info("ITSM ticket created, request held for SRE review",
225			"ticket", ticket, "tier", result.Tier.Name)
226	}
227
228	func (a *App) pollITSM(ctx context.Context) error {
229		t := time.NewTicker(a.cfg.Jira.PollInterval)

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

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

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

	log.Info("scoring access request")
	result, err := a.scorer.Score(ctx, a.ai, req)
	if err != nil {
		log.Error("scoring failed", "err", err)
		return
	}
	log.Info("risk scored",
		"score", result.Score,
		"tier", result.Tier.Name,
		"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
	}

	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.pendingMu.Lock()
	a.pending[id] = ticket
	a.pendingMu.Unlock()
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name)
}

func (a *App) pollITSM(ctx context.Context) error {
	t := time.NewTicker(a.cfg.Jira.PollInterval)


Cursor bugbot said this on the PR; Race allows duplicate processing of same request  High Severity  The a.pending map is only populated after the full handleRequest completes (AI scoring, reputation,…


Bugbot's right, that's a real TOCTOU race — the reservation has to land before the goroutine launches, not after the whole Jira roundtrip. Fix plan:

1. Pull the check-and-reserve into one critical section in `watchRequests` using `""` as an "in flight" sentinel.
2. `handleRequest` releases the reservation on any abort path (score error, auto-approve, Jira error) and overwrites it with the real ticket key on success. Use a `defer` with a `handled` flag so we can't leak a reservation by forgetting a branch.
3. `reconcileITSM` skips empty-string sentinels defensively.
4. Extract three small helpers (`reserveOrSkip`, `releasePending`, `recordTicket`) so the mutex handling lives in one place and can be unit-tested.
5. Add a concurrency test hammering `reserveOrSkip` to prove exactly one goroutine wins.


Edit

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

		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
}


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

		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
}

package televibe

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

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

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

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

	pending   map[string]string
	pendingMu sync.Mutex
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

	log.Info("scoring access request")
	result, err := a.scorer.Score(ctx, a.ai, req)
	if err != nil {
		log.Error("scoring failed", "err", err)
		return
	}
	log.Info("risk scored",
		"score", result.Score,
		"tier", result.Tier.Name,
		"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
	}

	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.pendingMu.Lock()
	a.pending[id] = ticket
	a.pendingMu.Unlock()
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name)
}

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

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

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

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

-      _, held := a.pending[req.GetName()]

+      if !a.reserveOrSkip(req.GetName()) {

+// 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

+func (a *App) reserveOrSkip(id string) bool {

+func (a *App) releasePending(id string) {

+func (a *App) recordTicket(id, ticket string) {


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

	log.Info("scoring access request")
	result, err := a.scorer.Score(ctx, a.ai, req)
	if err != nil {
		log.Error("scoring failed", "err", err)
		return
	}

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
	}


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
	}

package televibe

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

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

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

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

	pending   map[string]string
	pendingMu sync.Mutex
}

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

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

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

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

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

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

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

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

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

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

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

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

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

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-watcher.Done():
			return watcher.Error()
		case ev := <-watcher.Events():
			if ev.Type != types.OpPut {
				continue
			}
			req, ok := ev.Resource.(types.AccessRequest)
			if !ok {
				continue
			}
			if req.GetState() != types.RequestState_PENDING {
				continue
			}
			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)

	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
	}

	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.pendingMu.Lock()
	a.pending[id] = ticket
	a.pendingMu.Unlock()
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name)
}

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

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

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

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

+  // 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.


	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.pendingMu.Lock()
	a.pending[id] = ticket
	a.pendingMu.Unlock()
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name)
}

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


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

package televibe

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

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

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

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

	pending   map[string]string
	pendingMu sync.Mutex
}

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

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

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

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

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

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

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

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

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

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

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

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

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

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-watcher.Done():
			return watcher.Error()
		case ev := <-watcher.Events():
			if ev.Type != types.OpPut {
				continue
			}
			req, ok := ev.Resource.(types.AccessRequest)
			if !ok {
				continue
			}
			if req.GetState() != types.RequestState_PENDING {
				continue
			}
			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
	}

	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.pendingMu.Lock()
	a.pending[id] = ticket
	a.pendingMu.Unlock()
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name)
}

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

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

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

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


Now the defensive skip in `reconcileITSM` and the concurrency test.


	for requestID, ticket := range snap {
		status, err := a.jira.TicketStatus(ctx, ticket)

	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)


	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)

package televibe

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

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

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

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

	pending   map[string]string
	pendingMu sync.Mutex
}

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

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

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

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

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

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

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

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

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

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

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

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

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

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-watcher.Done():
			return watcher.Error()
		case ev := <-watcher.Events():
			if ev.Type != types.OpPut {
				continue
			}
			req, ok := ev.Resource.(types.AccessRequest)
			if !ok {
				continue
			}
			if req.GetState() != types.RequestState_PENDING {
				continue
			}
			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
	}

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

+      // Reservation for an in-flight handleRequest; nothing to poll yet.


Write

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


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/app_test.go

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


Bash

go build ./... 2>&1; echo '---tests---'; go test -race ./... 2>&1 | tail -10; echo '---app tests verbose---'; go test -race -v . -run 'Reserve' 2>&1 | tail -15

Build, run full race tests, and verify reserve tests specifically


---tests---
ok  	televibe	2.220s
?   	televibe/ai	[no test files]
?   	televibe/cmd/submit	[no test files]
?   	televibe/cmd/televibe	[no test files]
?   	televibe/jira	[no test files]
ok  	televibe/reputation	(cached)
ok  	televibe/risk	(cached)
?   	televibe/telemetry	[no test files]
---app tests verbose---
=== RUN   TestReserveOrSkip_ExactlyOneWinner
--- PASS: TestReserveOrSkip_ExactlyOneWinner (0.00s)
=== RUN   TestReserveOrSkip_AfterReleaseReacquires
--- PASS: TestReserveOrSkip_AfterReleaseReacquires (0.00s)
=== RUN   TestReserveOrSkip_TicketedIDIsNotReacquired
--- PASS: TestReserveOrSkip_TicketedIDIsNotReacquired (0.00s)
PASS
ok  	televibe	1.756s


Clean build, all tests green under `-race`.

What the fix does, end to end:

**`watchRequests`** now calls `reserveOrSkip(id)` which does check-and-insert inside a single critical section. The second goroutine bugbot was worried about now loses the race there and returns immediately — it never sees the AI call or the Jira client.

**`handleRequest`** installs a `defer` at the top that releases the reservation unless a `handled` flag is set. The flag flips true only after `recordTicket` stores the real Jira ticket key. That means:
- Scoring error → reservation released, next watcher event can retry.
- Auto-approved (score below threshold, with or without `SubmitAccessReview` errors) → released; if somehow another PENDING event arrived, we'd reprocess, but the state-check guard in the watcher loop already filters those out.
- Jira creation error → released, retriable on next event.
- Jira success → `recordTicket` + `handled = true`; reservation is now a real ticket key, poller takes over.

**`reconcileITSM`** skips empty ticket strings defensively so if the poller tick fires while a handler is mid-flight, we don't try `TicketStatus("")` against Jira.

**Tests** — `app_test.go` fires 100 goroutines at `reserveOrSkip` for the same id and asserts exactly one winner; plus tests for release-then-reacquire (the retry path) and that a ticketed id stays sealed.

One thing I weighed and backed away from, Sir Charles: making `reserveOrSkip` the poller's source of truth by iterating `pending` and filtering empties at every poll tick. It's already doing the right thing via the snapshot copy, so the defensive `if ticket == ""` skip is cheap insurance without changing the structure.


Building televibe, a Teleport access-request plugin with AI risk scoring; just fixed the TOCTOU race Cursor bugbot flagged by adding atomic reserve-or-skip dedup with concurrency tests. Next: wait for your direction on what to tackle.


bugbot also identified this, can you check if this is a problem? Zero-value defaults prevent disabling reputation penalties via config

Medium Severity

ApplyDefaults uses == 0 guards for PenaltyMissingCUSTX and PenaltyThinDescription, so a user who intentionally sets either penalty to 0 in the TOML config to disable it will have the value silently overwritten with the default (10 or 5). The test TestAssess_DisabledPenaltyIsSkipped acknowledges this by constructing the config after ApplyDefaults, but real config loading always runs ApplyDefaults, making the disable path unreachable from production config.


1	// Package reputation tracks per-user reputation scores derived from the
2	// quality of Teleport access requests. State is persisted to a local JSON
3	// file so no external database is required.
4	package reputation
5
6	import (
7		"context"
8		"encoding/json"
9		"errors"
10		"fmt"
11		"io/fs"
12		"os"
13		"path/filepath"
14		"sync"
15	)
16
17	type Config struct {
18		PersistPath            string `toml:"persist_path"`
19		StartingScore          int    `toml:"starting_score"`
20		FloorScore             int    `toml:"floor_score"`
21		CeilingScore           int    `toml:"ceiling_score"`
22		PenaltyMissingCUSTX    int    `toml:"penalty_missing_custx"`
23		PenaltyThinDescription int    `toml:"penalty_thin_description"`
24	}
25
26	func (c *Config) ApplyDefaults() {
27		if c.StartingScore == 0 {
28			c.StartingScore = 100
29		}
30		if c.CeilingScore == 0 {
31			c.CeilingScore = 100
32		}
33		if c.PenaltyMissingCUSTX == 0 {
34			c.PenaltyMissingCUSTX = 10
35		}
36		if c.PenaltyThinDescription == 0 {
37			c.PenaltyThinDescription = 5
38		}
39	}
40
41	func (c *Config) Validate() error {
42		if c.FloorScore > c.CeilingScore {
43			return fmt.Errorf("floor_score (%d) must be <= ceiling_score (%d)", c.FloorScore, c.CeilingScore)
44		}
45		if c.StartingScore < c.FloorScore || c.StartingScore > c.CeilingScore {
46			return fmt.Errorf("starting_score (%d) must be within [%d, %d]", c.StartingScore, c.FloorScore, c.CeilingScore)
47		}
48		return nil
49	}
50
51	type Penalty struct {
52		Reason string
53		Amount int
54	}
55
56	type Assessment struct {
57		Score     int
58		Previous  int
59		Penalties []Penalty
60	}
61
62	type Store struct {
63		cfg Config
64		mu  sync.RWMutex
65		rep map[string]int
66	}
67
68	func NewStore(cfg Config) (*Store, error) {
69		s := &Store{cfg: cfg, rep: make(map[string]int, 64)}
70		if cfg.PersistPath != "" {
71			if err := s.load(); err != nil {
72				return nil, err
73			}
74		}
75		return s, nil
76	}
77
78	// Score returns the current reputation for user, or StartingScore if unseen.
79	func (s *Store) Score(user string) int {
80		s.mu.RLock()
81		defer s.mu.RUnlock()
82		if v, ok := s.rep[user]; ok {
83			return v
84		}
85		return s.cfg.StartingScore
86	}
87
88	// Range invokes fn for every tracked user under a read lock. Do not call
89	// back into the store from fn or you will deadlock.
90	func (s *Store) Range(fn func(user string, score int)) {
91		s.mu.RLock()
92		defer s.mu.RUnlock()
93		for u, sc := range s.rep {
94			fn(u, sc)
95		}
96	}
97
98	// Assess translates risk components into configured penalties, docks the
99	// user's score, clamps to [floor, ceiling], persists to disk, and returns
100	// the updated assessment. A component map with no bad signals is a no-op.
101	func (s *Store) Assess(ctx context.Context, user string, components map[string]int) (Assessment, error) {
102		return s.apply(ctx, user, s.penaltiesFor(components))
103	}
104
105	func (s *Store) penaltiesFor(components map[string]int) []Penalty {
106		penalties := make([]Penalty, 0, 2)
107		if _, ok := components["missing_custx_ticket"]; ok && s.cfg.PenaltyMissingCUSTX > 0 {
108			penalties = append(penalties, Penalty{Reason: "missing_custx_ticket", Amount: s.cfg.PenaltyMissingCUSTX})
109		}
110		if _, ok := components["thin_description"]; ok && s.cfg.PenaltyThinDescription > 0 {
111			penalties = append(penalties, Penalty{Reason: "thin_description", Amount: s.cfg.PenaltyThinDescription})
112		}
113		return penalties
114	}
115
116	func (s *Store) apply(ctx context.Context, user string, penalties []Penalty) (Assessment, error) {
117		s.mu.Lock()
118		prev, ok := s.rep[user]
119		if !ok {
120			prev = s.cfg.StartingScore
121		}
122		score := prev
123		for _, p := range penalties {
124			score -= p.Amount
125		}
126		if score < s.cfg.FloorScore {
127			score = s.cfg.FloorScore
128		}
129		if score > s.cfg.CeilingScore {
130			score = s.cfg.CeilingScore
131		}
132		s.rep[user] = score
133		s.mu.Unlock()
134
135		assessment := Assessment{Score: score, Previous: prev, Penalties: penalties}
136		if s.cfg.PersistPath == "" || len(penalties) == 0 {
137			return assessment, nil
138		}
139		if err := s.persist(ctx); err != nil {
140			return assessment, fmt.Errorf("persist reputation: %w", err)
141		}
142		return assessment, nil
143	}
144
145	func (s *Store) load() error {
146		data, err := os.ReadFile(s.cfg.PersistPath)
147		if err != nil {
148			if errors.Is(err, fs.ErrNotExist) {
149				return nil
150			}
151			return fmt.Errorf("read reputation state: %w", err)
152		}
153		if len(data) == 0 {
154			return nil
155		}
156		var rep map[string]int
157		if err := json.Unmarshal(data, &rep); err != nil {
158			return fmt.Errorf("parse reputation state: %w", err)
159		}
160		s.rep = rep
161		return nil
162	}
163
164	func (s *Store) persist(_ context.Context) error {
165		s.mu.RLock()
166		data, err := json.Marshal(s.rep)
167		s.mu.RUnlock()
168		if err != nil {
169			return err
170		}
171
172		dir := filepath.Dir(s.cfg.PersistPath)
173		if err := os.MkdirAll(dir, 0o755); err != nil {
174			return err
175		}
176		tmp, err := os.CreateTemp(dir, ".reputation.*.tmp")
177		if err != nil {
178			return err
179		}
180		tmpPath := tmp.Name()
181		defer os.Remove(tmpPath)
182
183		if _, err := tmp.Write(data); err != nil {
184			tmp.Close()
185			return err
186		}
187		if err := tmp.Chmod(0o600); err != nil {
188			tmp.Close()
189			return err
190		}
191		if err := tmp.Sync(); err != nil {
192			tmp.Close()
193			return err
194		}
195		if err := tmp.Close(); err != nil {
196			return err
197		}
198		return os.Rename(tmpPath, s.cfg.PersistPath)
199	}
200

// Package reputation tracks per-user reputation scores derived from the
// quality of Teleport access requests. State is persisted to a local JSON
// file so no external database is required.
package reputation

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sync"
)

type Config struct {
	PersistPath            string `toml:"persist_path"`
	StartingScore          int    `toml:"starting_score"`
	FloorScore             int    `toml:"floor_score"`
	CeilingScore           int    `toml:"ceiling_score"`
	PenaltyMissingCUSTX    int    `toml:"penalty_missing_custx"`
	PenaltyThinDescription int    `toml:"penalty_thin_description"`
}

func (c *Config) ApplyDefaults() {
	if c.StartingScore == 0 {
		c.StartingScore = 100
	}
	if c.CeilingScore == 0 {
		c.CeilingScore = 100
	}
	if c.PenaltyMissingCUSTX == 0 {
		c.PenaltyMissingCUSTX = 10
	}
	if c.PenaltyThinDescription == 0 {
		c.PenaltyThinDescription = 5
	}
}

func (c *Config) Validate() error {
	if c.FloorScore > c.CeilingScore {
		return fmt.Errorf("floor_score (%d) must be <= ceiling_score (%d)", c.FloorScore, c.CeilingScore)
	}
	if c.StartingScore < c.FloorScore || c.StartingScore > c.CeilingScore {
		return fmt.Errorf("starting_score (%d) must be within [%d, %d]", c.StartingScore, c.FloorScore, c.CeilingScore)
	}
	return nil
}

type Penalty struct {
	Reason string
	Amount int
}

type Assessment struct {
	Score     int
	Previous  int
	Penalties []Penalty
}

type Store struct {
	cfg Config
	mu  sync.RWMutex
	rep map[string]int
}

func NewStore(cfg Config) (*Store, error) {
	s := &Store{cfg: cfg, rep: make(map[string]int, 64)}
	if cfg.PersistPath != "" {
		if err := s.load(); err != nil {
			return nil, err
		}
	}
	return s, nil
}

// Score returns the current reputation for user, or StartingScore if unseen.
func (s *Store) Score(user string) int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if v, ok := s.rep[user]; ok {
		return v
	}
	return s.cfg.StartingScore
}

// Range invokes fn for every tracked user under a read lock. Do not call
// back into the store from fn or you will deadlock.
func (s *Store) Range(fn func(user string, score int)) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for u, sc := range s.rep {
		fn(u, sc)
	}
}

// Assess translates risk components into configured penalties, docks the
// user's score, clamps to [floor, ceiling], persists to disk, and returns
// the updated assessment. A component map with no bad signals is a no-op.
func (s *Store) Assess(ctx context.Context, user string, components map[string]int) (Assessment, error) {
	return s.apply(ctx, user, s.penaltiesFor(components))
}

func (s *Store) penaltiesFor(components map[string]int) []Penalty {
	penalties := make([]Penalty, 0, 2)
	if _, ok := components["missing_custx_ticket"]; ok && s.cfg.PenaltyMissingCUSTX > 0 {
		penalties = append(penalties, Penalty{Reason: "missing_custx_ticket", Amount: s.cfg.PenaltyMissingCUSTX})
	}
	if _, ok := components["thin_description"]; ok && s.cfg.PenaltyThinDescription > 0 {
		penalties = append(penalties, Penalty{Reason: "thin_description", Amount: s.cfg.PenaltyThinDescription})
	}
	return penalties
}

func (s *Store) apply(ctx context.Context, user string, penalties []Penalty) (Assessment, error) {
	s.mu.Lock()
	prev, ok := s.rep[user]
	if !ok {
		prev = s.cfg.StartingScore
	}
	score := prev
	for _, p := range penalties {
		score -= p.Amount
	}
	if score < s.cfg.FloorScore {
		score = s.cfg.FloorScore
	}
	if score > s.cfg.CeilingScore {
		score = s.cfg.CeilingScore
	}
	s.rep[user] = score
	s.mu.Unlock()

	assessment := Assessment{Score: score, Previous: prev, Penalties: penalties}
	if s.cfg.PersistPath == "" || len(penalties) == 0 {
		return assessment, nil
	}
	if err := s.persist(ctx); err != nil {
		return assessment, fmt.Errorf("persist reputation: %w", err)
	}
	return assessment, nil
}

func (s *Store) load() error {
	data, err := os.ReadFile(s.cfg.PersistPath)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			return nil
		}
		return fmt.Errorf("read reputation state: %w", err)
	}
	if len(data) == 0 {
		return nil
	}
	var rep map[string]int
	if err := json.Unmarshal(data, &rep); err != nil {
		return fmt.Errorf("parse reputation state: %w", err)
	}
	s.rep = rep
	return nil
}

func (s *Store) persist(_ context.Context) error {
	s.mu.RLock()
	data, err := json.Marshal(s.rep)
	s.mu.RUnlock()
	if err != nil {
		return err
	}

	dir := filepath.Dir(s.cfg.PersistPath)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	tmp, err := os.CreateTemp(dir, ".reputation.*.tmp")
	if err != nil {
		return err
	}
	tmpPath := tmp.Name()
	defer os.Remove(tmpPath)

	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Chmod(0o600); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmpPath, s.cfg.PersistPath)
}


bugbot also identified this, can you check if this is a problem? Zero-value defaults prevent disabling reputation penalties via config  Medium Severity  ApplyDefaults uses == 0 guards for PenaltyMissi…


grep -rn "DisabledPenaltyIsSkipped\|TestAssess" /Users/steve.briggs/projects/operations/apps/televibe/reputation/ 2>&1

Locate the test bugbot references


/Users/steve.briggs/projects/operations/apps/televibe/reputation/reputation_test.go:26:func TestAssess_AppliesConfiguredPenalties(t *testing.T) {
/Users/steve.briggs/projects/operations/apps/televibe/reputation/reputation_test.go:51:func TestAssess_ClampsToFloor(t *testing.T) {
/Users/steve.briggs/projects/operations/apps/televibe/reputation/reputation_test.go:67:func TestAssess_NoSignalsIsNoOp(t *testing.T) {
/Users/steve.briggs/projects/operations/apps/televibe/reputation/reputation_test.go:151:func TestAssess_DisabledPenaltyIsSkipped(t *testing.T) {


145		}
146		if seen["alice"] != 90 || seen["bob"] != 95 {
147			t.Errorf("unexpected seen: %+v", seen)
148		}
149	}
150
151	func TestAssess_DisabledPenaltyIsSkipped(t *testing.T) {
152		cfg := baseCfg("")
153		cfg.PenaltyMissingCUSTX = 0 // explicit disable
154		cfg.PenaltyThinDescription = 5
155		cfg.StartingScore = 100
156		// ApplyDefaults already ran in baseCfg, but setting to 0 after is a valid
157		// "turn this off" gesture — ApplyDefaults would resurrect it, so rebuild:
158		s, _ := NewStore(cfg)
159
160		a, _ := s.Assess(context.Background(), "alice", map[string]int{
161			"missing_custx_ticket": 1,
162			"thin_description":     1,
163		})
164		if a.Score != 95 {
165			t.Errorf("Score = %d, want 95 (only thin_description applied)", a.Score)
166		}
167		for _, p := range a.Penalties {
168			if p.Reason == "missing_custx_ticket" {
169				t.Errorf("missing_custx_ticket penalty should be skipped when amount = 0")
170			}
171		}
172	}
173

	}
	if seen["alice"] != 90 || seen["bob"] != 95 {
		t.Errorf("unexpected seen: %+v", seen)
	}
}

func TestAssess_DisabledPenaltyIsSkipped(t *testing.T) {
	cfg := baseCfg("")
	cfg.PenaltyMissingCUSTX = 0 // explicit disable
	cfg.PenaltyThinDescription = 5
	cfg.StartingScore = 100
	// ApplyDefaults already ran in baseCfg, but setting to 0 after is a valid
	// "turn this off" gesture — ApplyDefaults would resurrect it, so rebuild:
	s, _ := NewStore(cfg)

	a, _ := s.Assess(context.Background(), "alice", map[string]int{
		"missing_custx_ticket": 1,
		"thin_description":     1,
	})
	if a.Score != 95 {
		t.Errorf("Score = %d, want 95 (only thin_description applied)", a.Score)
	}
	for _, p := range a.Penalties {
		if p.Reason == "missing_custx_ticket" {
			t.Errorf("missing_custx_ticket penalty should be skipped when amount = 0")
		}
	}
}


Yes, bugbot is right — and the test on line 151-172 even has a comment admitting the workaround (`// ApplyDefaults would resurrect it, so rebuild`). Setting a penalty to 0 in TOML gets silently overwritten.

Let me check where `applyDefaults` runs and whether flipping the order (defaults *before* TOML unmarshal) would fix it with minimal surgery.


grep -rn "ApplyDefaults\|applyDefaults" --include="*.go" /Users/steve.briggs/projects/operations/apps/televibe/ 2>&1

Find all ApplyDefaults call sites


/Users/steve.briggs/projects/operations/apps/televibe/config.go:47:	cfg.applyDefaults()
/Users/steve.briggs/projects/operations/apps/televibe/config.go:54:func (c *Config) applyDefaults() {
/Users/steve.briggs/projects/operations/apps/televibe/config.go:55:	c.Jira.ApplyDefaults()
/Users/steve.briggs/projects/operations/apps/televibe/config.go:56:	c.Risk.ApplyDefaults()
/Users/steve.briggs/projects/operations/apps/televibe/config.go:57:	c.Reputation.ApplyDefaults()
/Users/steve.briggs/projects/operations/apps/televibe/config.go:58:	c.Telemetry.ApplyDefaults()
/Users/steve.briggs/projects/operations/apps/televibe/reputation/reputation_test.go:12:	c.ApplyDefaults()
/Users/steve.briggs/projects/operations/apps/televibe/reputation/reputation_test.go:156:	// ApplyDefaults already ran in baseCfg, but setting to 0 after is a valid
/Users/steve.briggs/projects/operations/apps/televibe/reputation/reputation_test.go:157:	// "turn this off" gesture — ApplyDefaults would resurrect it, so rebuild:
/Users/steve.briggs/projects/operations/apps/televibe/reputation/reputation.go:26:func (c *Config) ApplyDefaults() {
/Users/steve.briggs/projects/operations/apps/televibe/jira/itsm.go:26:func (c *Config) ApplyDefaults() {
/Users/steve.briggs/projects/operations/apps/televibe/telemetry/telemetry.go:29:func (c *Config) ApplyDefaults() {
/Users/steve.briggs/projects/operations/apps/televibe/risk/risk.go:23:func (c *Config) ApplyDefaults() {


Bugbot is right — and the root cause isn't just in reputation; the same `== 0` footgun exists in `risk.ApplyDefaults` (try setting `ticket_threshold = 0` in TOML — you'll get 25 instead of an auto-approve-everything knob), `telemetry.ApplyDefaults`, and `jira.ApplyDefaults`. I could patch each field individually, but the clean structural fix is one line: **flip the order so defaults run before TOML unmarshal**. Then TOML fields that *are* present override the default (even with zero), and TOML fields that *aren't* present retain the default. This is how almost every other Go config system is layered.

Plan:
1. `LoadConfig`: move `cfg.applyDefaults()` to immediately after `cfg := &Config{}`, before the TOML unmarshal.
2. Update the stale comment in the reputation test.
3. Add an integration test that proves `penalty_missing_custx = 0` survives through `LoadConfig`.