dify/docs/design/prompt-challenges.md

633 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## Prompt Hacking Challenges — Design
### Overview
Enable developer-authored prompt hacking challenges inside Dify’s workflow builder via a new workflow node. Players can register/login using the existing web auth and compete on challenges. Attempts are recorded server-side and leaderboards are exposed via public web APIs.
### Goals
- Add a first-class workflow node that evaluates success/failure against developer-specified criteria.
- Add a Judging LLM node that compares model outputs to the challenge goal and produces pass/fail, textual feedback, and a 1–10 rating.
- Persist attempts with metadata for scoring and leaderboards.
- Reuse existing account/web auth for players.
- Fit Dify’s DDD/Clean Architecture: models, services, controllers, workflow nodes, and frontend builder integration.
### Non-Goals
- Anti-cheat measures beyond simple rate limiting.
- Complex custom scoring plugins (design leaves a hook for future work).
## Architecture Summary
### Backend components
- Models (SQLAlchemy)
- Challenge
- id, tenant_id, app_id, workflow_id
- name, description, goal (plain text shown to players)
- success_type: one of ['regex', 'contains', 'custom']
- success_pattern: string (regex or substring depending on type)
- secret_ref: reference to server-side secret (never exposed to clients)
- scoring_strategy: one of ['first', 'fastest', 'fewest_tokens', 'custom']
- is_active: bool
- created_by, created_at, updated_by, updated_at
- ChallengeAttempt
- id, tenant_id, challenge_id (FK), end_user_id (FK), workflow_run_id (optional FK)
- succeeded: bool
- score: numeric (meaning depends on strategy)
- judge_rating: int (0–10)
- judge_feedback: text
- judge_output_raw: jsonb (optional; structured judgement payload)
- tokens_total: int (when available from run metrics)
- elapsed_ms: int (when available)
- created_at
- Service layer (e.g., `ChallengeService`, `ChallengeJudgeService`)
- evaluate_outcome(output, cfg) -> (succeeded: bool, details: dict)
- judge_with_llm(goal, response, cfg) -> { passed: bool, rating: int, feedback: str, raw?: dict }
- evaluate_with_plugin(evaluator_ref, goal, response, ctx) -> { passed: bool, rating?: int, feedback?: str, raw?: dict }
- score_with_plugin(scorer_ref, attempt_metrics, ctx) -> { score: number, details?: dict }
- record_attempt(tenant_id, challenge_id, end_user_id, run_meta, succeeded) -> ChallengeAttempt
- get_leaderboard(challenge_id, limit, strategy) -> list
- get_challenge_public(challenge_id) -> dict
- Controllers
- Console (for creators): CRUD on challenges under the workspace (`/console/api/challenges`)
- Web (for players): public endpoints under `/web/api/challenges`
- List active challenges, fetch details, fetch leaderboard
- Optional auth via existing web login for personalization, otherwise anonymous read
- Workflow nodes
- NodeType: `challenge-evaluator`
- Config
- `challenge_id`: reference to a stored Challenge (preferred)
- or inline config: `success_type`, `success_pattern`, `scoring_strategy`
- `mask_variables`: string[] — variable names to redact in logs
- Execution
- Consumes upstream content (typically latest assistant output)
- Evaluates success with `ChallengeService.evaluate_outcome`
- If an `EndUser` context exists and a `challenge_id` is present, writes `ChallengeAttempt`
- Outputs `{ challenge_succeeded: boolean, message?: string }`, optionally passes through original output
- NodeType: `judging-llm`
- Purpose: judge a model response against the challenge goal using an LLM rubric.
- Config
- `judge_model`: provider/name/version
- `temperature`, `max_tokens`, other model params
- `rubric_prompt_template`: template with placeholders for {goal}, {response}, optional {hints}
- `rating_scale`: default 0–10; configurable upper bound optional
- `pass_threshold`: integer (default 5)
- Inputs
- `goal`: the attacking goal or acceptance criteria
- `response`: the model output to evaluate
- Execution
- Calls `ChallengeJudgeService.judge_with_llm()` to obtain structured judgement
- Returns outputs `{ judge_passed: boolean, judge_rating: number (0–10), judge_feedback: string, judge_raw?: object }`
- Integration
- Downstream `challenge-evaluator` can consume `judge_passed` and `judge_rating` to record an attempt instead of regex/contains
- Alternatively, `challenge-evaluator` may support an `evaluation_mode: 'rules' | 'llm-judge'` to invoke judging internally
- NodeType: `team-challenge` (Red/Blue orchestrator)
- Purpose: orchestrate two-sided challenges where players choose Red (attack) or Blue (defense) and submit prompts accordingly. The node pairs attacks and defenses, configures the LLM, invokes judging, and emits scores.
- Config
- `red_blue_challenge_id`: reference to a Red/Blue challenge definition
- `defense_selection_policy`: 'latest_best' | 'random_active' | 'round_robin' | 'request_new_if_none'
- `attack_selection_policy`: same options for the defense side evaluation path
- `judge_suite`: list of category tests to run (e.g., CBRNE, SA, SH, RWH, V, M)
- `scoring_strategy`: 'red_blue_ratio' | 'custom'
- Inputs
- `team_choice`: 'red' | 'blue'
- `attack_prompt?`: string (when `team_choice = 'red'`)
- `defense_prompt?`: string (when `team_choice = 'blue'`)
- Execution (high level)
- If `team_choice = 'red'`:
- Persist `attack_prompt` submission
- Load a defense by policy; if none, optionally signal Blue to provide one (async) and fall back to last known
- Configure LLM with defense as system prompt, submit attack as user message
- Run `judge_suite` via `judging-llm`; compute Red score
- If `team_choice = 'blue'`:
- Persist `defense_prompt` submission
- Load an attack by policy; if none, signal Red to provide one (async) and fall back to last known
- Configure LLM with defense as system prompt and submit the loaded attack
- Run `judge_suite`; compute Blue score (prevention)
- Persist pairing and metrics
- Outputs
- `{ team: 'red'|'blue', judge_passed: boolean, judge_rating: number, judge_feedback: string, categories: Record<string, boolean|number>, team_points: number, total_points: number }`
### Frontend components
- Workflow builder
- Add `Prompt Challenge` to the node palette
- Add `Judging LLM` to the node palette
- Node editor panel: select existing Challenge or define inline success criteria
- Judging panel: choose model, edit rubric prompt, set pass threshold, preview structured outputs
- Custom evaluator/scorer panels: choose plugin and configure JSON settings with live schema validation
- I18n strings in `web/i18n/en-US/`
- Challenge display & theming
- Author-provided instructions (Markdown) render before/alongside the task input area
- Theme tokens (colors, logo, background) applied to challenge pages
- Optional hero image/video via existing `UploadFile` and signed URLs
- Optional player UX (phase 2)
- `/challenges` list and `/challenges/[id]` details with leaderboard
- `/challenge-collections` list and `/challenge-collections/[id]` details with collection leaderboard
- Use existing web login endpoints
## Data Model
Minimal table shapes (final columns managed in migration):
```sql
-- challenges
id (uuid pk)
tenant_id (uuid fk)
app_id (uuid fk)
workflow_id (uuid fk)
name (text)
description (text)
goal (text)
success_type (text)
success_pattern (text)
secret_ref (text)
scoring_strategy (text)
is_active (bool)
created_by (uuid)
created_at (timestamp)
updated_by (uuid)
updated_at (timestamp)
-- challenge_attempts
id (uuid pk)
tenant_id (uuid fk)
challenge_id (uuid fk)
end_user_id (uuid fk)
workflow_run_id (uuid fk, nullable)
succeeded (bool)
score (numeric)
tokens_total (int)
elapsed_ms (int)
created_at (timestamp)
```
Additional columns for judging:
```sql
ALTER TABLE challenge_attempts
ADD COLUMN judge_rating integer,
ADD COLUMN judge_feedback text,
ADD COLUMN judge_output_raw jsonb;
```
Optional columns for custom evaluators/scorers:
```sql
ALTER TABLE challenges
ADD COLUMN evaluator_type text DEFAULT 'rules', -- one of: rules, llm-judge, custom
ADD COLUMN evaluator_plugin_id text,
ADD COLUMN evaluator_entrypoint text, -- e.g., "pkg.module:Evaluator"
ADD COLUMN evaluator_config jsonb,
ADD COLUMN scoring_plugin_id text,
ADD COLUMN scoring_entrypoint text, -- e.g., "pkg.module:Scorer"
ADD COLUMN scoring_config jsonb;
```
Additional tables for Red/Blue team challenges:
```sql
-- red_blue_challenges (definition)
CREATE TABLE red_blue_challenges (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id uuid NOT NULL REFERENCES tenants(id),
app_id uuid NOT NULL REFERENCES apps(id),
workflow_id uuid REFERENCES workflows(id),
name text NOT NULL,
description text,
judge_suite jsonb NOT NULL, -- list of categories/tests
defense_selection_policy text NOT NULL DEFAULT 'latest_best',
attack_selection_policy text NOT NULL DEFAULT 'latest_best',
scoring_strategy text NOT NULL DEFAULT 'red_blue_ratio',
theme jsonb,
instructions_md text,
is_active boolean NOT NULL DEFAULT true,
created_by uuid,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by uuid,
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- team_submissions (attack/defense prompts)
CREATE TABLE team_submissions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
red_blue_challenge_id uuid NOT NULL REFERENCES red_blue_challenges(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
account_id uuid NULL,
end_user_id uuid NULL,
team text NOT NULL CHECK (team in ('red','blue')),
prompt text NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- pairings (which attack tested against which defense)
CREATE TABLE team_pairings (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
red_blue_challenge_id uuid NOT NULL REFERENCES red_blue_challenges(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
attack_submission_id uuid REFERENCES team_submissions(id),
defense_submission_id uuid REFERENCES team_submissions(id),
judge_output_raw jsonb,
categories jsonb, -- e.g., per-suite pass/fail or rating
judge_rating integer,
judge_feedback text,
red_points numeric NOT NULL DEFAULT 0,
blue_points numeric NOT NULL DEFAULT 0,
tokens_total int,
elapsed_ms int,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
## API Design
### Console (creator)
- `GET /console/api/challenges?app_id=...` — list
- `POST /console/api/challenges` — create
- `GET /console/api/challenges/{id}` — retrieve
- `PATCH /console/api/challenges/{id}` — update
- `DELETE /console/api/challenges/{id}` — delete
All require console `login_required` and membership in tenant.
### Web (player)
- `GET /web/api/challenges` — list active challenges (public)
- `GET /web/api/challenges/{id}` — details (public)
- `GET /web/api/challenges/{id}/leaderboard?limit=...` — leaderboard (public)
Player login uses existing web login endpoints to obtain access token when needed for personalization.
### Collections
Console (creator)
- `GET /console/api/challenge-collections?app_id=...`
- `POST /console/api/challenge-collections`
- `GET /console/api/challenge-collections/{id}`
- `PATCH /console/api/challenge-collections/{id}`
- `DELETE /console/api/challenge-collections/{id}`
- `PUT /console/api/challenge-collections/{id}/challenges` (set membership and order)
Web (player)
- `GET /web/api/challenge-collections` — list public collections
- `GET /web/api/challenge-collections/{id}` — collection details (instructions/theme), included challenges
- `GET /web/api/challenge-collections/{id}/leaderboard?limit=...` — collection leaderboard
### Red/Blue team challenge APIs
Console (creator)
- `POST /console/api/red-blue-challenges` — create
- `GET /console/api/red-blue-challenges?app_id=...` — list
- `GET /console/api/red-blue-challenges/{id}` — detail
- `PATCH /console/api/red-blue-challenges/{id}` — update
- `DELETE /console/api/red-blue-challenges/{id}` — delete
- `GET /console/api/red-blue-challenges/{id}/pairings` — view pairings/metrics
Web (player)
- `POST /web/api/red-blue-challenges/{id}/join` — join red or blue (payload: { team })
- `POST /web/api/red-blue-challenges/{id}/submit` — submit attack/defense (payload: { team, prompt })
- `GET /web/api/red-blue-challenges/{id}` — public info (instructions, theme, leaderboard snapshot)
- `GET /web/api/red-blue-challenges/{id}/leaderboard?limit=...` — red vs blue standings
## Player Registration & Identity
### Registration and login
- Reuse existing web auth service for player accounts:
- Email/password login: `POST /web/api/login`
- Email code login: `POST /web/api/login/email-code/send` + `POST /web/api/login/email-code/verify` (existing patterns)
- Add an explicit web registration endpoint (thin wrapper around `RegisterService.register`):
- `POST /web/api/register` (payload: email, name, password | email-code)
- Behavior:
- `create_workspace_required = False` to avoid auto-creating workspaces for players
- `status = active`
- Set `interface_language` from `Accept-Language` as done in OAuth flow
- On success, also create or associate a per-tenant `EndUser` record so gameplay runs can be attributed consistently.
### Player identity during runs
- Each gameplay run already has an `EndUser` context. For registered players:
- When a player is authenticated, resolve (or lazily create) an `EndUser` tied to their `account_id` for the current tenant/app
- Persist `end_user_id` to `ChallengeAttempt` as today; optionally also store `account_id` for simplified leaderboard personalization
### Optional schema addition
```sql
ALTER TABLE challenge_attempts
ADD COLUMN account_id uuid NULL;
```
This enables direct joins to accounts for notification and profile display without traversing end-user mappings.
### Player profile (optional)
Introduce a lightweight `player_profiles` table for nickname/avatar/notification preferences without touching `account` directly:
```sql
CREATE TABLE player_profiles (
account_id uuid PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
display_name text,
avatar_url text,
notify_on_first_blood boolean DEFAULT true,
notify_on_record_beaten boolean DEFAULT true,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
## Workflow Node Execution
1. Node receives upstream output (string or structured content). A typical placement is after an LLM node.
2. Node loads Challenge config (stored by `challenge_id` or inline).
3. Node evaluates success by either rules or judging:
- `regex`: test pattern against text output
- `contains`: case-insensitive substring match
- `llm-judge`: call the `judging-llm` node (or internal judge) to obtain `{ judge_passed, judge_rating, judge_feedback }`
- `custom`: call `evaluate_with_plugin` using configured `evaluator_plugin_id`/`evaluator_entrypoint`
4. If `EndUser` and `challenge_id` present, record a `ChallengeAttempt` with run metrics (tokens, elapsed), and when available, `judge_rating`/`judge_feedback`.
5. Node outputs
- Rules mode: `{ challenge_succeeded: boolean, message?: string }`
- Judging mode: `{ challenge_succeeded: boolean, judge_rating: number, judge_feedback: string }`
- Pass through original output for chaining when needed.
For collections, attempts are recorded per challenge as usual. Collection leaderboard aggregation is computed over a player’s best attempt per challenge, combined using the collection’s `scoring_strategy` (e.g., sum of scores, total elapsed_ms, etc.).
## Scoring Strategies
- `first`: first successful attempt time wins (leaderboard sorted by earliest `created_at`).
- `fastest`: success with lowest `elapsed_ms` wins.
- `fewest_tokens`: success with lowest `tokens_total` wins.
- `highest_rating`: success with the highest `judge_rating` wins; ties broken by earliest `created_at`.
- `custom`: compute via `score_with_plugin` using `scoring_plugin_id`/`scoring_entrypoint`.
Collection strategies:
- `sum`: sum of per-challenge scores in the collection (uses built-in or custom scoring per challenge)
- `fastest_total`: sum of `elapsed_ms` of successful best attempts (lower is better)
- `fewest_tokens_total`: sum of `tokens_total` of successful best attempts (lower is better)
- `highest_avg_rating`: average of `judge_rating` across completed challenges (higher is better)
- `custom`: plugin-defined; service calls `score_with_plugin` at collection level with a list of per-challenge metrics
Red/Blue team scoring:
- Base idea: award Red points for breakthroughs and Blue points for prevented attacks.
- Suggested defaults per pairing:
- For each category in the judge suite (e.g., CBRNE, SA, SH, RWH, V, M):
- If the attack bypasses defense (category breach), Red +1
- If defense prevents (no breach), Blue +1
- Bonus based on `judge_rating` magnitude for breakthrough severity (e.g., Red +round(rating/3))
- Time/token penalties can reduce points to encourage efficient strategies
- Ratio-based standings:
- Red ratio = Red points / (Red points + Blue points)
- Blue ratio = Blue points / (Red points + Blue points)
- Custom plugin scoring:
- Provide all pairing metrics to a scorer plugin to compute per-pairing or cumulative standings
## Custom Evaluators & Scorers
This section specifies how custom evaluation and scoring plugins integrate with challenges.
### Concepts
- Evaluator: decides whether a response meets the goal. May optionally emit a rating (0–10) and textual feedback.
- Scorer: converts an attempt’s metrics (e.g., elapsed time, tokens, rating) into a numeric score for leaderboards.
### Data model
- `challenges.evaluator_type`: one of `rules`, `llm-judge`, or `custom`.
- `challenges.evaluator_plugin_id`, `evaluator_entrypoint`, `evaluator_config`: identify and configure the evaluator plugin when `custom` is selected.
- `challenges.scoring_plugin_id`, `scoring_entrypoint`, `scoring_config`: identify and configure the scorer plugin when `scoring_strategy = 'custom'`.
### Service interfaces
Evaluator interface (Python):
```python
class EvaluatorContext(TypedDict, total=False):
tenant_id: str
app_id: str
workflow_id: str
challenge_id: str
end_user_id: str | None
variables: dict[str, Any] # sanitized runtime variables
timeout_ms: int
class EvaluatorResult(TypedDict, total=False):
passed: bool
rating: int # 0–10 (optional)
feedback: str # textual feedback for player (optional)
raw: dict[str, Any] # internal diagnostics (optional)
class EvaluatorProtocol(Protocol):
def evaluate(self, goal: str, response: str, config: dict[str, Any], ctx: EvaluatorContext) -> EvaluatorResult: ...
```
Scorer interface (Python):
```python
class ScoringContext(TypedDict, total=False):
tenant_id: str
app_id: str
workflow_id: str
challenge_id: str
end_user_id: str | None
timeout_ms: int
class AttemptMetrics(TypedDict, total=False):
succeeded: bool
tokens_total: int | None
elapsed_ms: int | None
rating: int | None
created_at: int | None # epoch ms
class ScoringResult(TypedDict, total=False):
score: float
details: dict[str, Any] | None
class ScorerProtocol(Protocol):
def score(self, metrics: AttemptMetrics, config: dict[str, Any], ctx: ScoringContext) -> ScoringResult: ...
```
### Discovery and loading
- Plugins are discovered via the existing plugin manager. Each plugin exposes one or more entrypoints (e.g., `pkg.module:Evaluator`).
- `evaluator_plugin_id`/`evaluator_entrypoint` and `scoring_plugin_id`/`scoring_entrypoint` identify the target callables.
- Services load plugins lazily and cache handles with safe import guards.
### Execution flow
1) For `evaluator_type = 'custom'`, the `challenge-evaluator` node calls `evaluate_with_plugin` with `(goal, response, evaluator_config, ctx)`.
2) If `EvaluatorResult.passed` is true, set `challenge_succeeded = True` and persist `judge_rating`/`judge_feedback` if provided.
3) For `scoring_strategy = 'custom'`, call `score_with_plugin` with attempt metrics to compute `score`.
4) Persist `ChallengeAttempt` with plugin-derived fields.
### Frontend configuration
- Prompt Challenge panel
- Evaluation mode: Rules | Judging LLM | Custom Evaluator
- When Custom Evaluator is chosen:
- Plugin selector: lists available evaluator plugins by `plugin_id` and exposed entrypoints
- JSON config editor with schema-based validation (optional `$schema` per plugin)
- Scoring section
- Strategy: First | Fastest | Fewest Tokens | Highest Rating | Custom
- When Custom is chosen: plugin selector + JSON config editor
### Security & sandboxing
- Plugins run under server control with:
- Timeouts (default 5s) and memory ceilings; cancellation on overrun
- No network access by default (opt-in allowlist if ever needed)
- Sanitized inputs: secrets removed; only whitelisted variables passed
- Structured error mapping; no stack traces leaked to players
### Error handling & observability
- If plugin load or execution fails, treat as non-pass and record a generic failure reason.
- Emit structured logs/events with plugin identifiers and durations (no sensitive content).
- Surface minimal feedback to players; detailed diagnostics remain internal.
### Examples
Evaluator (substring with banned terms):
```python
class SimpleEvaluator:
def evaluate(self, goal, response, config, ctx):
required = config.get('must_contain', [])
banned = set(map(str.lower, config.get('banned', [])))
if any(w.lower() in response.lower() for w in banned):
return {'passed': False, 'feedback': 'Banned content detected', 'rating': 2}
if all(w.lower() in response.lower() for w in required):
return {'passed': True, 'feedback': 'Meets criteria', 'rating': 8}
return {'passed': False, 'feedback': 'Missing required signal', 'rating': 5}
```
Scorer (weighted combo):
```python
class WeightedScorer:
def score(self, metrics, config, ctx):
base = 0.0
if metrics.get('succeeded'):
base += config.get('success_bonus', 100)
rating = metrics.get('rating') or 0
elapsed = metrics.get('elapsed_ms') or 0
tokens = metrics.get('tokens_total') or 0
score = base + rating * config.get('rating_weight', 10) \
- (elapsed / 1000.0) * config.get('time_penalty', 1.0) \
- tokens * config.get('token_penalty', 0.01)
return {'score': max(score, 0.0)}
```
## Security & Privacy
- Never expose `secret_ref` or derived secrets to clients or node outputs.
- Redact configured `mask_variables` in logs and stored attempt details.
- Apply rate limiting using existing helpers to mitigate brute-force attempts.
- Store minimal details on failed attempts to reduce information leakage.
- Sanitize Markdown instructions to prevent XSS; allow a safe subset (links/images) with rel=noopener.
- Theme application is constrained to a whitelist of CSS variables and asset URLs served via signed URLs.
## Testing Plan
- Service unit tests
- `evaluate_outcome` for regex/contains (edge cases, unicode, multiline)
- `judge_with_llm` deterministic tests with mocked LLM returning structured payloads
- `record_attempt` scoring aggregation and sorting
- Node tests
- Given inputs, assert success/failure and resulting outputs
- Judging node: asserts `{ judge_passed, judge_rating, judge_feedback }` shape and thresholds
- When `challenge_id` present, attempts are written; when not, none are written
- API tests
- Console CRUD happy paths and permissions
- Web endpoints list/details/leaderboard
- Frontend
- Panel validation, serialization/deserialization of node config
- Judging panel: model selection, rubric template binding, threshold validation
- Node palette presence
- Challenge instructions: Markdown renderer sanitization, link and image handling
- Theming: verify CSS variable injection, dark/light modes, and fallback to defaults
- Collections UI: ordering, visibility filtering, collection leaderboard rendering
## Rollout
1. DB migrations: create `challenges`, `challenge_attempts` tables; add judging columns.
2. Backend: models, service, console/web controllers, workflow node, `NodeType` and node mapping registration.
3. Frontend: add block enum, node + panel components (Prompt Challenge, Judging LLM), node palette default, i18n entries.
4. QA: run `make lint`, `make type-check`, and unit tests; `pnpm lint` and tests for web.
5. Documentation: link this design from contributor docs as needed.
## Open Questions / Future Work
- Anti-cheat signals and anomaly detection.
- Custom evaluator/scoring plugin hooks with sandboxing.
- Team competitions and seasons.
- Per-challenge rate limits and cooldowns.
## Notifications
### Events
- `challenge_first_blood`: emitted when the first successful attempt occurs for a challenge
- `challenge_record_beaten`: emitted when a leaderboard record is surpassed under the active scoring strategy
- `team_pairing_completed`: emitted after each Red/Blue pairing is judged with per-team points
### Delivery channels
- In-app (console): add a section in the console UI for challenge events; poll or use server-sent events
- Email (optional): send via existing email task infra (e.g., Celery tasks)
- Webhook (optional): per-tenant webhook endpoint configured in workspace settings to receive challenge events
### Payloads
```json
{
"event": "challenge_record_beaten",
"challenge_id": "...",
"scoring_strategy": "highest_rating",
"previous_record": { "account_id": "...", "score": 95.2 },
"new_record": { "account_id": "...", "score": 96.8 },
"occurred_at": 1730000000000
}
```
Red/Blue pairing example:
```json
{
"event": "team_pairing_completed",
"red_blue_challenge_id": "...",
"pairing_id": "...",
"attack_submission_id": "...",
"defense_submission_id": "...",
"categories": { "CBRNE": true, "SA": false, "SH": true },
"judge_rating": 8,
"red_points": 4,
"blue_points": 2,
"occurred_at": 1730000000001
}
```
### Triggers in services
- After `record_attempt`, re-evaluate leaderboard head for the challenge against the prior head
- If the head changed and meets trigger criteria, enqueue notification tasks
- Respect player profile preferences (`notify_on_first_blood`, `notify_on_record_beaten`)
### Player-facing feedback
- Immediate feedback comes from node outputs (e.g., `judge_feedback`, `judge_rating`)
- Aggregated notifications (record beaten, first blood) are async and opt-in per player preferences