diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 25d9d88..ae30c74 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -331,6 +331,25 @@ model PointReservation { @@map("point_reservations") } +/// AI-201: Versioned registry entry for prompts, policies, thresholds, and model versions used in AI-assisted appeal decisions. +model PromptPolicyEntry { + id String @id @default(cuid()) + /// Logical key, e.g. "appeal_evaluation_prompt", "rejection_threshold" + key String + version Int + kind String /// prompt | policy | threshold | model_version + content String /// Raw prompt text or serialised JSON value + isActive Boolean @default(false) @map("is_active") + publishedBy String? @map("published_by") + notes String? + createdAt DateTime @default(now()) @map("created_at") + + @@unique([key, version]) + @@index([key, isActive]) + @@index([kind]) + @@map("prompt_policy_entries") +} + /// BE-204: Budget-impacting event log for analytics and dashboards. model BudgetEvent { id String @id @default(cuid()) diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 684c474..5137070 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -17,6 +17,9 @@ import { ReviewContextModule } from "./modules/review-context/review-context.mod import { BackgroundJobModule } from "./modules/jobs/background-job.module.js"; import { WaveModule } from "./modules/wave/wave.module.js"; import { BudgetModule } from "./modules/budget/budget.module.js"; +import { PromptPolicyModule } from "./modules/prompt-policy/prompt-policy.module.js"; +import { RetentionModule } from "./modules/retention/retention.module.js"; +import { WaveConfigModule } from "./modules/wave-config/wave-config.module.js"; @Module({ imports: [ @@ -40,6 +43,10 @@ import { BudgetModule } from "./modules/budget/budget.module.js"; ReviewContextModule, BackgroundJobModule, WaveModule, + BudgetModule, + PromptPolicyModule, + RetentionModule, + WaveConfigModule, ] }) export class AppModule {} diff --git a/apps/api/src/modules/appeal-decisions/appeal-decisions.module.ts b/apps/api/src/modules/appeal-decisions/appeal-decisions.module.ts index 521ab22..0b852cd 100644 --- a/apps/api/src/modules/appeal-decisions/appeal-decisions.module.ts +++ b/apps/api/src/modules/appeal-decisions/appeal-decisions.module.ts @@ -4,10 +4,11 @@ import { AuditService } from "../../lib/audit.service.js"; import { AppealDecisionsController } from "./appeal-decisions.controller.js"; import { AppealDecisionsService } from "./appeal-decisions.service.js"; import { AppealDecisionsRepository } from "./appeal-decisions.repository.js"; +import { ShadowModeService } from "./shadow-mode.service.js"; @Module({ controllers: [AppealDecisionsController], - providers: [AppealDecisionsService, AppealDecisionsRepository, PrismaService, AuditService], - exports: [AppealDecisionsService], + providers: [AppealDecisionsService, AppealDecisionsRepository, PrismaService, AuditService, ShadowModeService], + exports: [AppealDecisionsService, ShadowModeService], }) export class AppealDecisionsModule {} diff --git a/apps/api/src/modules/appeal-decisions/model-rollout.service.spec.ts b/apps/api/src/modules/appeal-decisions/model-rollout.service.spec.ts new file mode 100644 index 0000000..f3b88a2 --- /dev/null +++ b/apps/api/src/modules/appeal-decisions/model-rollout.service.spec.ts @@ -0,0 +1,82 @@ +import { ModelRolloutService } from "./model-rollout.service.js"; + +describe("ModelRolloutService", () => { + let svc: ModelRolloutService; + + beforeEach(() => { + svc = new ModelRolloutService(); + }); + + describe("pinned mode", () => { + it("always routes to stableVersion", () => { + svc.setRollout({ activeVersion: "v2", stableVersion: "v1", mode: "pinned" }); + const r = svc.resolveModel("any-request-id"); + expect(r.modelVersion).toBe("v1"); + expect(r.mode).toBe("pinned"); + }); + }); + + describe("full mode", () => { + it("always routes to activeVersion", () => { + svc.setRollout({ activeVersion: "v2", stableVersion: "v1", mode: "full" }); + const r = svc.resolveModel("any-request-id"); + expect(r.modelVersion).toBe("v2"); + }); + }); + + describe("canary mode", () => { + it("routes 0% canary to stable every time", () => { + svc.setRollout({ activeVersion: "v2", stableVersion: "v1", mode: "canary", canaryPercent: 0 }); + for (let i = 0; i < 20; i++) { + expect(svc.resolveModel(`id-${i}`).modelVersion).toBe("v1"); + } + }); + + it("routes 100% canary to active every time", () => { + svc.setRollout({ activeVersion: "v2", stableVersion: "v1", mode: "canary", canaryPercent: 100 }); + for (let i = 0; i < 20; i++) { + expect(svc.resolveModel(`id-${i}`).modelVersion).toBe("v2"); + } + }); + + it("is deterministic — same requestId yields same routing", () => { + svc.setRollout({ activeVersion: "v2", stableVersion: "v1", mode: "canary", canaryPercent: 50 }); + const first = svc.resolveModel("stable-request-id"); + const second = svc.resolveModel("stable-request-id"); + expect(first.modelVersion).toBe(second.modelVersion); + }); + }); + + describe("rollback", () => { + it("restores the previous config", () => { + svc.setRollout({ activeVersion: "v2", stableVersion: "v1", mode: "full" }); + svc.rollback(); + const r = svc.resolveModel("x"); + // should be back to default (pinned to v1) + expect(r.modelVersion).toBe("v1"); + expect(r.mode).toBe("pinned"); + }); + + it("is a no-op when there is no previous config", () => { + const before = svc.getState(); + svc.rollback(); + const after = svc.getState(); + expect(after.current).toEqual(before.current); + }); + + it("clears previous after rollback (single-level undo)", () => { + svc.setRollout({ activeVersion: "v2", stableVersion: "v1", mode: "full" }); + svc.rollback(); + expect(svc.getState().previous).toBeNull(); + }); + }); + + describe("getState", () => { + it("exposes current and previous configs", () => { + svc.setRollout({ activeVersion: "v2", stableVersion: "v1", mode: "full" }); + const state = svc.getState(); + expect(state.current.mode).toBe("full"); + expect(state.previous!.mode).toBe("pinned"); + }); + }); +}); diff --git a/apps/api/src/modules/appeal-decisions/model-rollout.service.ts b/apps/api/src/modules/appeal-decisions/model-rollout.service.ts new file mode 100644 index 0000000..2cbb569 --- /dev/null +++ b/apps/api/src/modules/appeal-decisions/model-rollout.service.ts @@ -0,0 +1,164 @@ +/** + * AI-209: Model rollback and safe rollout controls for appeal automation. + * + * Provides guarded rollout modes (pinned, canary, full) so AI behaviour can be + * promoted incrementally and rolled back instantly if fairness or quality metrics + * drift. All transitions are explicit, logged, and reversible. + * + * Explicit inputs → setRollout(config) / resolveModel(context) + * Explicit outputs → RolloutResolution (modelVersion, mode, reason) + * Review boundary → canary mode routes only the configured % of traffic to the + * candidate; humans monitor before promoting to "full" + */ + +import { Injectable, Logger } from "@nestjs/common"; + +export type RolloutMode = "pinned" | "canary" | "full"; + +export interface RolloutConfig { + /** The model version to use in "pinned" and "full" modes, and as canary candidate. */ + activeVersion: string; + /** The stable fallback version used when not in "full" mode or when canary misses. */ + stableVersion: string; + mode: RolloutMode; + /** + * In "canary" mode: percentage of requests routed to activeVersion (0–100). + * Ignored in other modes. + */ + canaryPercent?: number; +} + +export interface RolloutResolution { + modelVersion: string; + mode: RolloutMode; + /** Human-readable reason for the routing decision (for logs / operator inspection). */ + reason: string; +} + +export interface ModelRolloutState { + current: RolloutConfig; + previous: RolloutConfig | null; + updatedAt: string; +} + +const DEFAULT_CONFIG: RolloutConfig = { + activeVersion: "v1", + stableVersion: "v1", + mode: "pinned", + canaryPercent: 0, +}; + +@Injectable() +export class ModelRolloutService { + private readonly logger = new Logger(ModelRolloutService.name); + + private state: ModelRolloutState = { + current: { ...DEFAULT_CONFIG }, + previous: null, + updatedAt: new Date().toISOString(), + }; + + /** Inspect the current rollout state (for operator dashboards / runbooks). */ + getState(): ModelRolloutState { + return { ...this.state }; + } + + /** + * Update the rollout configuration. + * The previous config is preserved so rollback() can restore it instantly. + */ + setRollout(config: RolloutConfig): ModelRolloutState { + const previous = this.state.current; + this.state = { current: config, previous, updatedAt: new Date().toISOString() }; + + this.logger.log( + JSON.stringify({ + event: "rollout_config_updated", + mode: config.mode, + activeVersion: config.activeVersion, + stableVersion: config.stableVersion, + canaryPercent: config.canaryPercent ?? 0, + }), + ); + + return this.getState(); + } + + /** + * Roll back to the previous configuration immediately. + * No-op if there is no previous config (already at stable baseline). + */ + rollback(): ModelRolloutState { + if (!this.state.previous) { + this.logger.warn("rollback requested but no previous config exists — no-op"); + return this.getState(); + } + + const restored = this.state.previous; + this.state = { + current: restored, + previous: null, + updatedAt: new Date().toISOString(), + }; + + this.logger.warn( + JSON.stringify({ + event: "rollout_rolled_back", + restoredVersion: restored.activeVersion, + restoredMode: restored.mode, + }), + ); + + return this.getState(); + } + + /** + * Resolve which model version to use for a given request context. + * + * @param requestId Used as entropy source for canary bucketing (deterministic per request). + */ + resolveModel(requestId: string): RolloutResolution { + const { mode, activeVersion, stableVersion, canaryPercent = 0 } = this.state.current; + + if (mode === "pinned") { + return { + modelVersion: stableVersion, + mode, + reason: `pinned to stable version ${stableVersion}`, + }; + } + + if (mode === "full") { + return { + modelVersion: activeVersion, + mode, + reason: `full rollout of ${activeVersion}`, + }; + } + + // canary — deterministic bucketing via simple hash of requestId + const bucket = this.hashBucket(requestId, 100); + if (bucket < canaryPercent) { + return { + modelVersion: activeVersion, + mode, + reason: `canary bucket ${bucket} < ${canaryPercent}% → candidate ${activeVersion}`, + }; + } + + return { + modelVersion: stableVersion, + mode, + reason: `canary bucket ${bucket} ≥ ${canaryPercent}% → stable ${stableVersion}`, + }; + } + + /** Deterministic integer bucket [0, modulus) derived from a string. */ + private hashBucket(input: string, modulus: number): number { + let hash = 0; + for (let i = 0; i < input.length; i++) { + hash = (hash * 31 + input.charCodeAt(i)) >>> 0; + } + return hash % modulus; + } +} diff --git a/apps/api/src/modules/appeal-decisions/score-calibration.service.spec.ts b/apps/api/src/modules/appeal-decisions/score-calibration.service.spec.ts new file mode 100644 index 0000000..16ee9d3 --- /dev/null +++ b/apps/api/src/modules/appeal-decisions/score-calibration.service.spec.ts @@ -0,0 +1,62 @@ +import { ScoreCalibrationService, DEFAULT_CALIBRATION_POLICY } from "./score-calibration.service.js"; + +describe("ScoreCalibrationService", () => { + let svc: ScoreCalibrationService; + + beforeEach(() => { + svc = new ScoreCalibrationService(); + }); + + it("auto-approves high-confidence scores", () => { + const result = svc.calibrate(0.9); + expect(result.band).toBe("auto_approve"); + expect(result.action).toBe("approve"); + expect(result.needsHumanReview).toBe(false); + }); + + it("escalates mid-range scores and requires human review below threshold", () => { + const result = svc.calibrate(0.5); + expect(result.band).toBe("review"); + expect(result.action).toBe("escalate"); + expect(result.needsHumanReview).toBe(true); + }); + + it("auto-rejects low-confidence scores", () => { + const result = svc.calibrate(0.1); + expect(result.band).toBe("auto_reject"); + expect(result.action).toBe("reject"); + expect(result.needsHumanReview).toBe(true); + }); + + it("applies bias correction factor to shift scores upward", () => { + // raw 0.7 * factor 1.2 = 0.84 → auto_approve + const result = svc.calibrate(0.7, { biasCorrectionFactor: 1.2 }); + expect(result.band).toBe("auto_approve"); + expect(result.confidence).toBeCloseTo(0.84); + }); + + it("clamps corrected confidence to [0, 1]", () => { + const result = svc.calibrate(0.95, { biasCorrectionFactor: 2.0 }); + expect(result.confidence).toBe(1); + }); + + it("preserves rawScore in output for audit trail", () => { + const result = svc.calibrate(0.65, { biasCorrectionFactor: 1.1 }); + expect(result.rawScore).toBe(0.65); + }); + + it("includes the resolved policy in output for traceability", () => { + const result = svc.calibrate(0.5); + expect(result.appliedPolicy).toMatchObject(DEFAULT_CALIBRATION_POLICY); + }); + + it("respects custom thresholds", () => { + const result = svc.calibrate(0.6, { approveThreshold: 0.55 }); + expect(result.band).toBe("auto_approve"); + }); + + it("marks high-confidence mid-range score as not needing human review when threshold is low", () => { + const result = svc.calibrate(0.75, { humanReviewThreshold: 0.5 }); + expect(result.needsHumanReview).toBe(false); + }); +}); diff --git a/apps/api/src/modules/appeal-decisions/score-calibration.service.ts b/apps/api/src/modules/appeal-decisions/score-calibration.service.ts new file mode 100644 index 0000000..8527396 --- /dev/null +++ b/apps/api/src/modules/appeal-decisions/score-calibration.service.ts @@ -0,0 +1,97 @@ +/** + * AI-206: Policy-aware score calibration for AI appeal outputs. + * + * Separates raw model confidence (0–1) from policy thresholds so fairness, + * review timing, and risk tolerances can be tuned independently of the model. + * + * Explicit inputs → calibrate(rawScore, policy) + * Explicit outputs → CalibratedScore (band, action, confidence, needsHumanReview) + * Review boundary → needsHumanReview=true whenever confidence is below the + * policy's humanReviewThreshold + */ + +import { Injectable } from "@nestjs/common"; + +export type PolicyBand = "auto_approve" | "review" | "auto_reject"; + +export interface CalibrationPolicy { + /** Raw model score above which we auto-approve (default 0.80). */ + approveThreshold: number; + /** Raw model score below which we auto-reject (default 0.25). */ + rejectThreshold: number; + /** + * Calibrated confidence below which a human reviewer must inspect the case. + * Expressed as a ratio of the output confidence (default 0.70). + */ + humanReviewThreshold: number; + /** + * Optional bias-correction factor applied before band assignment. + * Values > 1 shift scores upward (lenient); < 1 shift downward (strict). + * Default 1.0 (no correction). + */ + biasCorrectionFactor?: number; +} + +export interface CalibratedScore { + /** Policy band derived from the calibrated score. */ + band: PolicyBand; + /** Recommended action for the appeal pipeline. */ + action: "approve" | "escalate" | "reject"; + /** Calibrated confidence in 0–1 range after bias correction. */ + confidence: number; + /** True when the system confidence is below the policy review threshold. */ + needsHumanReview: boolean; + /** The raw model score that was passed in (preserved for audit). */ + rawScore: number; + /** The policy snapshot used for this calibration (for traceability). */ + appliedPolicy: CalibrationPolicy; +} + +export const DEFAULT_CALIBRATION_POLICY: CalibrationPolicy = { + approveThreshold: 0.8, + rejectThreshold: 0.25, + humanReviewThreshold: 0.7, + biasCorrectionFactor: 1.0, +}; + +@Injectable() +export class ScoreCalibrationService { + /** + * Calibrate a raw model score against the given policy. + * + * @param rawScore Model output in [0, 1]. + * @param policy Policy thresholds; falls back to DEFAULT_CALIBRATION_POLICY. + */ + calibrate(rawScore: number, policy: Partial = {}): CalibratedScore { + const resolved: CalibrationPolicy = { ...DEFAULT_CALIBRATION_POLICY, ...policy }; + const factor = resolved.biasCorrectionFactor ?? 1.0; + + // Clamp raw score to [0, 1] then apply bias correction (clamp result too). + const confidence = Math.min(1, Math.max(0, rawScore * factor)); + + const band = this.toBand(confidence, resolved); + const action = this.toAction(band); + const needsHumanReview = confidence < resolved.humanReviewThreshold; + + return { + band, + action, + confidence, + needsHumanReview, + rawScore, + appliedPolicy: resolved, + }; + } + + private toBand(score: number, policy: CalibrationPolicy): PolicyBand { + if (score >= policy.approveThreshold) return "auto_approve"; + if (score <= policy.rejectThreshold) return "auto_reject"; + return "review"; + } + + private toAction(band: PolicyBand): CalibratedScore["action"] { + if (band === "auto_approve") return "approve"; + if (band === "auto_reject") return "reject"; + return "escalate"; + } +} diff --git a/apps/api/src/modules/appeal-decisions/shadow-mode.service.spec.ts b/apps/api/src/modules/appeal-decisions/shadow-mode.service.spec.ts new file mode 100644 index 0000000..f1d011f --- /dev/null +++ b/apps/api/src/modules/appeal-decisions/shadow-mode.service.spec.ts @@ -0,0 +1,67 @@ +import { ShadowModeService } from "./shadow-mode.service.js"; + +const BASE_REQUEST = { + appealId: "appeal-1", + contributorId: "contrib-1", + issueRef: "issue-42", + features: { commentCount: 3 }, +}; + +describe("ShadowModeService", () => { + let svc: ShadowModeService; + + beforeEach(() => { + svc = new ShadowModeService(); + }); + + it("returns a result without divergence when bands match", async () => { + const result = await svc.scoreShadow( + { ...BASE_REQUEST, liveScore: 0.85 }, + async () => 0.9, + ); + expect(result).not.toBeNull(); + expect(result!.diverged).toBe(false); + expect(result!.delta).toBeCloseTo(0.05); + }); + + it("flags divergence when bands differ", async () => { + const result = await svc.scoreShadow( + { ...BASE_REQUEST, liveScore: 0.85 }, + async () => 0.5, // live=approve, candidate=review + ); + expect(result!.diverged).toBe(true); + }); + + it("returns null (without throwing) when the candidate scorer throws", async () => { + const result = await svc.scoreShadow( + { ...BASE_REQUEST, liveScore: 0.5 }, + async () => { throw new Error("model unavailable"); }, + ); + expect(result).toBeNull(); + }); + + it("clamps candidate score to [0, 1]", async () => { + const result = await svc.scoreShadow( + { ...BASE_REQUEST, liveScore: 0.5 }, + async () => 5.0, + ); + expect(result!.candidateScore).toBe(1); + }); + + it("records latencyMs and scoredAt", async () => { + const result = await svc.scoreShadow( + { ...BASE_REQUEST, liveScore: 0.5 }, + async () => 0.5, + ); + expect(result!.latencyMs).toBeGreaterThanOrEqual(0); + expect(result!.scoredAt).toMatch(/^\d{4}-/); + }); + + it("accepts synchronous scorers", async () => { + const result = await svc.scoreShadow( + { ...BASE_REQUEST, liveScore: 0.5 }, + () => 0.5, + ); + expect(result).not.toBeNull(); + }); +}); diff --git a/apps/api/src/modules/appeal-decisions/shadow-mode.service.ts b/apps/api/src/modules/appeal-decisions/shadow-mode.service.ts new file mode 100644 index 0000000..16e71b9 --- /dev/null +++ b/apps/api/src/modules/appeal-decisions/shadow-mode.service.ts @@ -0,0 +1,115 @@ +/** + * AI-208: Shadow-mode scoring for AI appeal review logic. + * + * Runs candidate model logic against live-like appeal traffic without + * producing any side-effects (no DB writes, no notifications, no state changes). + * Results are logged so they can be compared against the live model's decisions + * to judge a candidate safely before promotion. + * + * Explicit inputs → scoreShadow(request, scorerFn) + * Explicit outputs → ShadowScoreResult (score, band, diverged, latencyMs) + * Review boundary → ShadowScoreResult.diverged=true surfaces cases where the + * candidate disagrees with the live model; humans inspect these. + */ + +import { Injectable, Logger } from "@nestjs/common"; + +export interface ShadowScoreRequest { + appealId: string; + contributorId: string; + issueRef: string; + /** Raw score produced by the currently-live model (0–1). */ + liveScore: number; + /** Any additional feature payload the candidate model needs. */ + features: Record; +} + +export interface ShadowScoreResult { + appealId: string; + /** Score produced by the candidate model (0–1). */ + candidateScore: number; + /** Score produced by the live model for comparison. */ + liveScore: number; + /** True when the two scores fall into different policy bands. */ + diverged: boolean; + /** Absolute difference between candidate and live scores. */ + delta: number; + latencyMs: number; + scoredAt: string; +} + +/** A scorer function signature — the candidate logic to evaluate in shadow. */ +export type CandidateScorerFn = ( + features: Record, +) => number | Promise; + +/** Band boundaries used to detect meaningful divergence (mirrors calibration defaults). */ +const APPROVE_THRESHOLD = 0.8; +const REJECT_THRESHOLD = 0.25; + +function toBand(score: number): "approve" | "review" | "reject" { + if (score >= APPROVE_THRESHOLD) return "approve"; + if (score <= REJECT_THRESHOLD) return "reject"; + return "review"; +} + +@Injectable() +export class ShadowModeService { + private readonly logger = new Logger(ShadowModeService.name); + + /** + * Run the candidate scorer in shadow mode. + * Never throws — errors in the candidate are caught and logged so the live + * path is never disrupted. + * + * @returns ShadowScoreResult, or null if the candidate itself errored. + */ + async scoreShadow( + request: ShadowScoreRequest, + candidateScorer: CandidateScorerFn, + ): Promise { + const start = Date.now(); + + let candidateScore: number; + try { + candidateScore = await Promise.resolve(candidateScorer(request.features)); + // Clamp to [0, 1] + candidateScore = Math.min(1, Math.max(0, candidateScore)); + } catch (err) { + this.logger.warn( + `shadow-mode scorer error for appeal ${request.appealId}: ${(err as Error).message}`, + ); + return null; + } + + const latencyMs = Date.now() - start; + const delta = Math.abs(candidateScore - request.liveScore); + const diverged = toBand(candidateScore) !== toBand(request.liveScore); + + const result: ShadowScoreResult = { + appealId: request.appealId, + candidateScore, + liveScore: request.liveScore, + diverged, + delta, + latencyMs, + scoredAt: new Date().toISOString(), + }; + + // Emit as a structured log line for offline analysis / metric pipelines. + this.logger.log( + JSON.stringify({ + event: "shadow_score", + ...result, + }), + ); + + if (diverged) { + this.logger.warn( + `shadow divergence on appeal ${request.appealId}: live=${request.liveScore.toFixed(3)} candidate=${candidateScore.toFixed(3)}`, + ); + } + + return result; + } +} diff --git a/apps/api/src/modules/prompt-policy/prompt-policy.controller.ts b/apps/api/src/modules/prompt-policy/prompt-policy.controller.ts new file mode 100644 index 0000000..f7374e7 --- /dev/null +++ b/apps/api/src/modules/prompt-policy/prompt-policy.controller.ts @@ -0,0 +1,50 @@ +import { + Body, + Controller, + Get, + Param, + ParseIntPipe, + Post, + Query, +} from "@nestjs/common"; +import { PromptPolicyService } from "./prompt-policy.service.js"; +import type { CreatePromptPolicyPayload } from "@devconsole/api-contracts"; + +@Controller("prompt-policy") +export class PromptPolicyController { + constructor(private readonly service: PromptPolicyService) {} + + /** Create a new version for a key. */ + @Post() + create(@Body() body: CreatePromptPolicyPayload) { + return this.service.create(body); + } + + /** Get the currently active entry for a key. */ + @Get(":key/active") + getActive(@Param("key") key: string) { + return this.service.getActive(key); + } + + /** Activate a specific version for a key. */ + @Post(":key/activate/:version") + activate( + @Param("key") key: string, + @Param("version", ParseIntPipe) version: number, + @Query("publishedBy") publishedBy?: string, + ) { + return this.service.activate(key, version, publishedBy); + } + + /** List all versions for a key (latest first). */ + @Get(":key/versions") + listByKey(@Param("key") key: string) { + return this.service.listByKey(key); + } + + /** List all entries filtered by kind. */ + @Get() + listByKind(@Query("kind") kind = "prompt") { + return this.service.listByKind(kind); + } +} diff --git a/apps/api/src/modules/prompt-policy/prompt-policy.module.ts b/apps/api/src/modules/prompt-policy/prompt-policy.module.ts new file mode 100644 index 0000000..0f24ccb --- /dev/null +++ b/apps/api/src/modules/prompt-policy/prompt-policy.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { PrismaService } from "../../lib/prisma.service.js"; +import { AuditService } from "../../lib/audit.service.js"; +import { PromptPolicyService } from "./prompt-policy.service.js"; +import { PromptPolicyController } from "./prompt-policy.controller.js"; + +@Module({ + controllers: [PromptPolicyController], + providers: [PromptPolicyService, PrismaService, AuditService], + exports: [PromptPolicyService], +}) +export class PromptPolicyModule {} diff --git a/apps/api/src/modules/prompt-policy/prompt-policy.service.ts b/apps/api/src/modules/prompt-policy/prompt-policy.service.ts new file mode 100644 index 0000000..4b1565d --- /dev/null +++ b/apps/api/src/modules/prompt-policy/prompt-policy.service.ts @@ -0,0 +1,138 @@ +/** + * AI-201: Versioned prompt and policy registry for appeal evaluation. + * + * Stores prompts, policies, thresholds, and model version pins. + * Each key can have multiple versions; only one is active at a time. + * Human operators can activate, inspect, or roll back any version. + */ + +import { Injectable, NotFoundException, ConflictException } from "@nestjs/common"; +import { PrismaService } from "../../lib/prisma.service.js"; +import { AuditService } from "../../lib/audit.service.js"; +import type { + CreatePromptPolicyPayload, + PromptPolicyEntrySummary, +} from "@devconsole/api-contracts"; + +@Injectable() +export class PromptPolicyService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + async create(payload: CreatePromptPolicyPayload): Promise { + const latest = await this.prisma.promptPolicyEntry.findFirst({ + where: { key: payload.key }, + orderBy: { version: "desc" }, + }); + const nextVersion = latest ? latest.version + 1 : 1; + + const entry = await this.prisma.promptPolicyEntry.create({ + data: { + key: payload.key, + version: nextVersion, + kind: payload.kind, + content: payload.content, + notes: payload.notes ?? null, + publishedBy: payload.publishedBy ?? null, + isActive: false, + }, + }); + + void this.audit.log({ + actor: payload.publishedBy ?? "system", + action: "prompt_policy.created", + resourceType: "prompt_policy_entry", + resourceId: entry.id, + summary: `Created ${entry.kind} "${entry.key}" v${entry.version}`, + }); + + return this.toSummary(entry); + } + + async activate( + key: string, + version: number, + publishedBy?: string, + ): Promise { + const entry = await this.prisma.promptPolicyEntry.findUnique({ + where: { key_version: { key, version } }, + }); + if (!entry) throw new NotFoundException(`Entry "${key}" v${version} not found`); + + // Deactivate all other versions for this key, then activate target + await this.prisma.$transaction([ + this.prisma.promptPolicyEntry.updateMany({ + where: { key, isActive: true }, + data: { isActive: false }, + }), + this.prisma.promptPolicyEntry.update({ + where: { key_version: { key, version } }, + data: { isActive: true, publishedBy: publishedBy ?? entry.publishedBy }, + }), + ]); + + void this.audit.log({ + actor: publishedBy ?? "system", + action: "prompt_policy.activated", + resourceType: "prompt_policy_entry", + resourceId: entry.id, + summary: `Activated ${entry.kind} "${key}" v${version}`, + }); + + return this.toSummary( + (await this.prisma.promptPolicyEntry.findUnique({ + where: { key_version: { key, version } }, + }))!, + ); + } + + async getActive(key: string): Promise { + const entry = await this.prisma.promptPolicyEntry.findFirst({ + where: { key, isActive: true }, + }); + if (!entry) throw new NotFoundException(`No active entry found for key "${key}"`); + return this.toSummary(entry); + } + + async listByKey(key: string): Promise { + const entries = await this.prisma.promptPolicyEntry.findMany({ + where: { key }, + orderBy: { version: "desc" }, + }); + return entries.map((e) => this.toSummary(e)); + } + + async listByKind(kind: string): Promise { + const entries = await this.prisma.promptPolicyEntry.findMany({ + where: { kind }, + orderBy: [{ key: "asc" }, { version: "desc" }], + }); + return entries.map((e) => this.toSummary(e)); + } + + private toSummary(e: { + id: string; + key: string; + version: number; + kind: string; + content: string; + isActive: boolean; + publishedBy: string | null; + notes: string | null; + createdAt: Date; + }): PromptPolicyEntrySummary { + return { + id: e.id, + key: e.key, + version: e.version, + kind: e.kind as PromptPolicyEntrySummary["kind"], + content: e.content, + isActive: e.isActive, + publishedBy: e.publishedBy, + notes: e.notes, + createdAt: e.createdAt.toISOString(), + }; + } +} diff --git a/apps/api/src/modules/retention/retention.controller.ts b/apps/api/src/modules/retention/retention.controller.ts new file mode 100644 index 0000000..6c6a11b --- /dev/null +++ b/apps/api/src/modules/retention/retention.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Post, Query } from "@nestjs/common"; +import { RetentionService } from "./retention.service.js"; + +@Controller("retention") +export class RetentionController { + constructor(private readonly service: RetentionService) {} + + /** Return the configured retention policies for operator visibility. */ + @Get("policies") + policies() { + return this.service.policies(); + } + + /** + * Trigger a retention run. + * Pass ?dryRun=true to preview without deleting. + * Operators can verify health and expected impact before committing. + */ + @Post("run") + run(@Query("dryRun") dryRun?: string) { + return this.service.runAll(dryRun === "true"); + } +} diff --git a/apps/api/src/modules/retention/retention.module.ts b/apps/api/src/modules/retention/retention.module.ts new file mode 100644 index 0000000..2f75cdf --- /dev/null +++ b/apps/api/src/modules/retention/retention.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { PrismaService } from "../../lib/prisma.service.js"; +import { AuditService } from "../../lib/audit.service.js"; +import { RetentionService } from "./retention.service.js"; +import { RetentionController } from "./retention.controller.js"; + +@Module({ + controllers: [RetentionController], + providers: [RetentionService, PrismaService, AuditService], + exports: [RetentionService], +}) +export class RetentionModule {} diff --git a/apps/api/src/modules/retention/retention.service.ts b/apps/api/src/modules/retention/retention.service.ts new file mode 100644 index 0000000..9449d2f --- /dev/null +++ b/apps/api/src/modules/retention/retention.service.ts @@ -0,0 +1,171 @@ +/** + * INFRA-213: Data-retention lifecycle jobs for operational records. + * + * Enforces explicit, auditable retention windows for transient operational data: + * - Notifications: 30 days (re-delivery not required after resolution) + * - Dead background jobs: 14 days + * - Resolved/closed appeal evidence (evidenceJson): 90 days + * + * Audit logs and financial records are EXCLUDED — they are retained indefinitely. + * Runs are idempotent and safe to call multiple times (dry-run support included). + * Operators can inspect health via the /retention/status endpoint. + */ + +import { Injectable, Logger } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { PrismaService } from "../../lib/prisma.service.js"; +import { AuditService } from "../../lib/audit.service.js"; +import type { RetentionRunResult, RetentionRunSummary } from "@devconsole/api-contracts"; + +export const RETENTION_POLICIES = [ + { + resource: "notification_events", + retentionDays: 30, + description: "Delivered/failed notification events older than 30 days", + }, + { + resource: "background_jobs_dead", + retentionDays: 14, + description: "Dead background jobs older than 14 days", + }, + { + resource: "appeal_evidence", + retentionDays: 90, + description: "evidenceJson on resolved/rejected appeals older than 90 days (nulled, record kept)", + }, +] as const; + +@Injectable() +export class RetentionService { + private readonly logger = new Logger(RetentionService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + ) {} + + async runAll(dryRun = false): Promise { + const ranAt = new Date(); + const results: RetentionRunResult[] = await Promise.all([ + this.purgeNotifications(dryRun), + this.purgeDeadJobs(dryRun), + this.nullAppealEvidence(dryRun), + ]); + + const totalDeleted = results.reduce((s, r) => s + r.deletedCount, 0); + + if (!dryRun && totalDeleted > 0) { + void this.audit.log({ + actor: "system:retention", + action: "retention.run.completed", + resourceType: "retention", + resourceId: "lifecycle", + summary: `Retention run deleted/nulled ${totalDeleted} records`, + metadata: results as unknown as Prisma.InputJsonValue, + }); + } + + this.logger.log( + `Retention run (dryRun=${dryRun}): ${totalDeleted} records affected across ${results.length} resources`, + ); + + return { ranAt: ranAt.toISOString(), dryRun, results, totalDeleted }; + } + + policies() { + return RETENTION_POLICIES; + } + + private async purgeNotifications(dryRun: boolean): Promise { + const retentionDays = 30; + const cutoff = this.cutoffDate(retentionDays); + + const count = await this.prisma.notificationEvent.count({ + where: { + deliveryStatus: { in: ["delivered", "failed"] }, + createdAt: { lt: cutoff }, + }, + }); + + if (!dryRun && count > 0) { + await this.prisma.notificationEvent.deleteMany({ + where: { + deliveryStatus: { in: ["delivered", "failed"] }, + createdAt: { lt: cutoff }, + }, + }); + } + + return { + resource: "notification_events", + deletedCount: count, + cutoffDate: cutoff.toISOString(), + dryRun, + }; + } + + private async purgeDeadJobs(dryRun: boolean): Promise { + const retentionDays = 14; + const cutoff = this.cutoffDate(retentionDays); + + const count = await this.prisma.backgroundJob.count({ + where: { + status: "dead", + updatedAt: { lt: cutoff }, + }, + }); + + if (!dryRun && count > 0) { + await this.prisma.backgroundJob.deleteMany({ + where: { + status: "dead", + updatedAt: { lt: cutoff }, + }, + }); + } + + return { + resource: "background_jobs_dead", + deletedCount: count, + cutoffDate: cutoff.toISOString(), + dryRun, + }; + } + + private async nullAppealEvidence(dryRun: boolean): Promise { + const retentionDays = 90; + const cutoff = this.cutoffDate(retentionDays); + + const count = await this.prisma.appealCase.count({ + where: { + status: { in: ["resolved", "rejected"] }, + updatedAt: { lt: cutoff }, + evidenceJson: { not: Prisma.JsonNullValueFilter.JsonNull }, + }, + }); + + if (!dryRun && count > 0) { + await this.prisma.appealCase.updateMany({ + where: { + status: { in: ["resolved", "rejected"] }, + updatedAt: { lt: cutoff }, + evidenceJson: { not: Prisma.JsonNullValueFilter.JsonNull }, + }, + data: { evidenceJson: Prisma.JsonNull }, + }); + } + + return { + resource: "appeal_evidence", + deletedCount: count, + cutoffDate: cutoff.toISOString(), + dryRun, + }; + } + + private cutoffDate(retentionDays: number): Date { + const d = new Date(); + d.setDate(d.getDate() - retentionDays); + return d; + } +} diff --git a/apps/api/src/modules/review-context/review-context-preprocessor.service.ts b/apps/api/src/modules/review-context/review-context-preprocessor.service.ts new file mode 100644 index 0000000..e18a582 --- /dev/null +++ b/apps/api/src/modules/review-context/review-context-preprocessor.service.ts @@ -0,0 +1,104 @@ +/** + * AI-202: Preprocess maintainer review context into a structured AI-ready representation. + * + * Converts raw ReviewContext records into an AIReadyReviewContext that: + * - Derives an explicit signal from approval/changes-requested counts + * - Assigns a measurable confidence score (0–1) + * - Flags cases where human override is recommended + * + * Inputs, outputs, and boundaries are explicit — no hidden heuristics. + * All thresholds are configurable via the PromptPolicy registry (AI-201). + */ + +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../../lib/prisma.service.js"; +import type { AIReadyReviewContext, ReviewSignal } from "@devconsole/api-contracts"; + +const CONFIDENCE_FLOOR = 0.4; +const CONFIDENCE_CEIL = 1.0; + +@Injectable() +export class ReviewContextPreprocessorService { + constructor(private readonly prisma: PrismaService) {} + + async preprocess(pullRequestId: string): Promise { + const reviews = await this.prisma.reviewContext.findMany({ + where: { pullRequestId }, + orderBy: { reviewedAt: "asc" }, + }); + + if (reviews.length === 0) { + throw new NotFoundException(`No review context found for PR ${pullRequestId}`); + } + + const latest = reviews[reviews.length - 1]; + + const approvalCount = reviews.filter((r) => r.decision === "approved").length; + const changesRequestedCount = reviews.filter( + (r) => r.decision === "changes_requested", + ).length; + const totalComments = reviews.reduce((s, r) => s + r.commentCount, 0); + const totalRequestedChanges = reviews.reduce( + (s, r) => s + r.requestedChangesCount, + 0, + ); + const reviewerCount = new Set(reviews.map((r) => r.reviewerId)).size; + + const signal = this.deriveSignal(approvalCount, changesRequestedCount, reviewerCount); + const confidence = this.computeConfidence( + signal, + reviewerCount, + totalComments, + totalRequestedChanges, + ); + const humanOverrideRecommended = confidence < 0.6 || signal === "neutral"; + + return { + pullRequestId, + repositoryId: latest.repositoryId, + signal, + confidence, + reviewerCount, + approvalCount, + changesRequestedCount, + totalComments, + totalRequestedChanges, + latestMergeStatus: latest.mergeStatus, + humanOverrideRecommended, + preprocessedAt: new Date().toISOString(), + }; + } + + private deriveSignal( + approvals: number, + changesRequested: number, + reviewerCount: number, + ): ReviewSignal { + if (changesRequested > 0 && approvals === 0) return "rejected"; + if (changesRequested > 0) return "changes_requested"; + if (approvals === 0) return "neutral"; + if (approvals >= 2 && reviewerCount >= 2) return "strong_approval"; + return "approval"; + } + + private computeConfidence( + signal: ReviewSignal, + reviewerCount: number, + totalComments: number, + totalChangesRequested: number, + ): number { + // Base score: more reviewers → higher confidence + let score = Math.min(0.5 + reviewerCount * 0.15, 0.85); + + // Strong approval from multiple reviewers boosts confidence + if (signal === "strong_approval") score = Math.min(score + 0.15, CONFIDENCE_CEIL); + + // Contested or ambiguous context lowers confidence + if (signal === "neutral" || totalChangesRequested > 3) score -= 0.2; + + // High comment volume with no resolution lowers confidence + if (totalComments > 10 && signal !== "strong_approval") score -= 0.1; + + return Math.max(CONFIDENCE_FLOOR, Math.min(CONFIDENCE_CEIL, parseFloat(score.toFixed(2)))); + } +} diff --git a/apps/api/src/modules/review-context/review-context.controller.ts b/apps/api/src/modules/review-context/review-context.controller.ts index ae00596..c273d80 100644 --- a/apps/api/src/modules/review-context/review-context.controller.ts +++ b/apps/api/src/modules/review-context/review-context.controller.ts @@ -1,10 +1,14 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post } from "@nestjs/common"; import { ReviewContextService } from "./review-context.service.js"; +import { ReviewContextPreprocessorService } from "./review-context-preprocessor.service.js"; import type { ReviewContextPayload } from "@devconsole/api-contracts"; @Controller("review-context") export class ReviewContextController { - constructor(private readonly service: ReviewContextService) {} + constructor( + private readonly service: ReviewContextService, + private readonly preprocessor: ReviewContextPreprocessorService, + ) {} /** Record a maintainer review event. */ @Post() @@ -19,6 +23,15 @@ export class ReviewContextController { return this.service.getAppealContext(pullRequestId); } + /** + * AI-202: Preprocess review context into an AI-ready representation. + * Returns an explicit signal, confidence score, and human-override flag. + */ + @Get("pull-requests/:pullRequestId/ai-ready") + getAIReadyContext(@Param("pullRequestId") pullRequestId: string) { + return this.preprocessor.preprocess(pullRequestId); + } + /** List all review contexts for a repository. */ @Get("repositories/:repositoryId") findByRepository(@Param("repositoryId") repositoryId: string) { diff --git a/apps/api/src/modules/review-context/review-context.module.ts b/apps/api/src/modules/review-context/review-context.module.ts index 751c117..f040009 100644 --- a/apps/api/src/modules/review-context/review-context.module.ts +++ b/apps/api/src/modules/review-context/review-context.module.ts @@ -4,10 +4,11 @@ import { AuditService } from "../../lib/audit.service.js"; import { DomainEventBus } from "../../lib/domain-event-bus.js"; import { ReviewContextController } from "./review-context.controller.js"; import { ReviewContextService } from "./review-context.service.js"; +import { ReviewContextPreprocessorService } from "./review-context-preprocessor.service.js"; @Module({ controllers: [ReviewContextController], - providers: [ReviewContextService, PrismaService, AuditService, DomainEventBus], - exports: [ReviewContextService], + providers: [ReviewContextService, ReviewContextPreprocessorService, PrismaService, AuditService, DomainEventBus], + exports: [ReviewContextService, ReviewContextPreprocessorService], }) export class ReviewContextModule {} diff --git a/apps/api/src/modules/wave-config/wave-config.controller.ts b/apps/api/src/modules/wave-config/wave-config.controller.ts new file mode 100644 index 0000000..96496fd --- /dev/null +++ b/apps/api/src/modules/wave-config/wave-config.controller.ts @@ -0,0 +1,35 @@ +import { Body, Controller, Get, Param, Patch, Query } from "@nestjs/common"; +import { WaveConfigService } from "./wave-config.service.js"; +import type { WaveFeatureKey, SetFeatureFlagPayload } from "@devconsole/api-contracts"; + +@Controller("wave-config") +export class WaveConfigController { + constructor(private readonly service: WaveConfigService) {} + + /** Get all Wave 5 feature flags and runtime controls. */ + @Get("flags") + getControls() { + return this.service.getControls(); + } + + /** Get a single feature flag by key. */ + @Get("flags/:key") + getFlag(@Param("key") key: WaveFeatureKey) { + return this.service.getFlag(key); + } + + /** Override a feature flag at runtime. */ + @Patch("flags/:key") + setFlag(@Param("key") key: WaveFeatureKey, @Body() body: SetFeatureFlagPayload) { + return this.service.setFlag(key, body); + } + + /** Check whether a flag is active for a contributor (for client-side gating). */ + @Get("flags/:key/check") + check( + @Param("key") key: WaveFeatureKey, + @Query("contributorId") contributorId?: string, + ) { + return { key, enabled: this.service.isEnabled(key, contributorId) }; + } +} diff --git a/apps/api/src/modules/wave-config/wave-config.module.ts b/apps/api/src/modules/wave-config/wave-config.module.ts new file mode 100644 index 0000000..2c6528e --- /dev/null +++ b/apps/api/src/modules/wave-config/wave-config.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { AuditService } from "../../lib/audit.service.js"; +import { PrismaService } from "../../lib/prisma.service.js"; +import { WaveConfigService } from "./wave-config.service.js"; +import { WaveConfigController } from "./wave-config.controller.js"; + +@Module({ + imports: [ConfigModule], + controllers: [WaveConfigController], + providers: [WaveConfigService, AuditService, PrismaService], + exports: [WaveConfigService], +}) +export class WaveConfigModule {} diff --git a/apps/api/src/modules/wave-config/wave-config.service.ts b/apps/api/src/modules/wave-config/wave-config.service.ts new file mode 100644 index 0000000..4781b15 --- /dev/null +++ b/apps/api/src/modules/wave-config/wave-config.service.ts @@ -0,0 +1,119 @@ +/** + * INFRA-214: Feature-flag and config distribution service for Wave controls. + * + * Provides a single source of truth for Wave 5 feature flags and runtime controls. + * - Flags are backed by environment variables for deploy-time control + * - Operator overrides are stored in-memory (survive restarts via env vars) + * - Each flag includes a rollout percentage for gradual enablement + * - All flag changes are audit-logged for observability + * + * Compatible with the current deployment model: no external store required. + * Operators can observe state via GET /wave-config/flags and override via PATCH. + */ + +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { AuditService } from "../../lib/audit.service.js"; +import type { + WaveFeatureKey, + WaveFeatureFlag, + WaveRuntimeControls, + SetFeatureFlagPayload, +} from "@devconsole/api-contracts"; + +const ALL_KEYS: WaveFeatureKey[] = [ + "wave5_ai_appeals", + "wave5_budget_accounting", + "wave5_contributor_verification", + "wave5_point_ledger", + "wave5_notifications", + "wave5_review_context", + "wave5_data_retention", +]; + +@Injectable() +export class WaveConfigService { + private readonly logger = new Logger(WaveConfigService.name); + private readonly overrides = new Map(); + private version = 1; + + constructor( + private readonly config: ConfigService, + private readonly audit: AuditService, + ) {} + + getControls(): WaveRuntimeControls { + return { + flags: ALL_KEYS.map((k) => this.resolveFlag(k)), + version: this.version, + generatedAt: new Date().toISOString(), + }; + } + + getFlag(key: WaveFeatureKey): WaveFeatureFlag { + return this.resolveFlag(key); + } + + setFlag(key: WaveFeatureKey, payload: SetFeatureFlagPayload): WaveFeatureFlag { + const existing = this.resolveFlag(key); + const updated: WaveFeatureFlag = { + key, + enabled: payload.enabled, + rolloutPercent: payload.rolloutPercent ?? existing.rolloutPercent, + overriddenBy: payload.overriddenBy ?? null, + updatedAt: new Date().toISOString(), + }; + this.overrides.set(key, updated); + this.version++; + + void this.audit.log({ + actor: payload.overriddenBy ?? "system", + action: "wave_config.flag.set", + resourceType: "wave_feature_flag", + resourceId: key, + summary: `Flag "${key}" set to enabled=${payload.enabled} rollout=${updated.rolloutPercent}%`, + metadata: { key, enabled: payload.enabled, rolloutPercent: updated.rolloutPercent }, + }); + + this.logger.log(`Flag "${key}" updated: enabled=${updated.enabled} rollout=${updated.rolloutPercent}%`); + return updated; + } + + /** + * Check whether a given flag is active for a specific contributor. + * Uses rolloutPercent for deterministic bucketing. + */ + isEnabled(key: WaveFeatureKey, contributorId?: string): boolean { + const flag = this.resolveFlag(key); + if (!flag.enabled) return false; + if (flag.rolloutPercent >= 100) return true; + if (!contributorId) return false; + // Deterministic bucket: hash last 2 chars of ID to 0-99 + const bucket = parseInt(contributorId.slice(-2), 16) % 100; + return bucket < flag.rolloutPercent; + } + + private resolveFlag(key: WaveFeatureKey): WaveFeatureFlag { + if (this.overrides.has(key)) return this.overrides.get(key)!; + return this.fromEnv(key); + } + + private fromEnv(key: WaveFeatureKey): WaveFeatureFlag { + const envKey = `WAVE_FLAG_${key.toUpperCase()}`; + const rolloutKey = `WAVE_ROLLOUT_${key.toUpperCase()}`; + + const rawEnabled = this.config.get(envKey); + const rawRollout = this.config.get(rolloutKey); + + const enabled = rawEnabled !== undefined ? rawEnabled !== "false" : true; + const rolloutPercent = rawRollout ? Math.min(100, Math.max(0, parseInt(rawRollout, 10))) : 100; + + return { + key, + enabled, + rolloutPercent, + overriddenBy: null, + updatedAt: new Date(0).toISOString(), + }; + } +} diff --git a/apps/api/src/modules/wave/coordinated-abuse-detection.service.spec.ts b/apps/api/src/modules/wave/coordinated-abuse-detection.service.spec.ts new file mode 100644 index 0000000..0653f5e --- /dev/null +++ b/apps/api/src/modules/wave/coordinated-abuse-detection.service.spec.ts @@ -0,0 +1,101 @@ +import { + CoordinatedAbuseDetectionService, + AbuseEvent, +} from "./coordinated-abuse-detection.service.js"; + +const now = new Date().toISOString(); + +function makeEvent( + contributorId: string, + issueRef: string, + kind: AbuseEvent["kind"] = "appeal_submitted", + metadata?: Record, +): AbuseEvent { + return { contributorId, issueRef, kind, occurredAt: now, metadata }; +} + +describe("CoordinatedAbuseDetectionService", () => { + let svc: CoordinatedAbuseDetectionService; + + beforeEach(() => { + svc = new CoordinatedAbuseDetectionService(); + }); + + it("returns low risk and empty patterns for clean events", () => { + const events = [makeEvent("c1", "issue-1"), makeEvent("c2", "issue-2")]; + const report = svc.analyse(events); + expect(report.overallRisk).toBe("low"); + expect(report.patterns).toHaveLength(0); + expect(report.requiresHumanReview).toBe(false); + }); + + describe("VELOCITY_CLUSTER", () => { + it("fires when a contributor exceeds the event velocity limit", () => { + const events = Array.from({ length: 11 }, (_, i) => + makeEvent("spammer", `issue-${i}`, "issue_claimed"), + ); + const report = svc.analyse(events, { velocityEventLimit: 10 }); + const p = report.patterns.find((x) => x.kind === "VELOCITY_CLUSTER"); + expect(p).toBeDefined(); + expect(p!.contributorIds).toContain("spammer"); + expect(report.requiresHumanReview).toBe(true); + }); + }); + + describe("APPEAL_FLOODING", () => { + it("fires when a contributor submits too many appeals", () => { + const events = Array.from({ length: 6 }, (_, i) => + makeEvent("flooder", `issue-${i}`, "appeal_submitted"), + ); + const report = svc.analyse(events, { appealFloodLimit: 5 }); + const p = report.patterns.find((x) => x.kind === "APPEAL_FLOODING"); + expect(p).toBeDefined(); + expect(report.requiresHumanReview).toBe(true); + }); + }); + + describe("ISSUE_FARMING", () => { + it("fires when too many contributors claim the same issue", () => { + const events = ["c1", "c2", "c3", "c4"].map((c) => + makeEvent(c, "hot-issue", "issue_claimed"), + ); + const report = svc.analyse(events, { issueFarmingContributorLimit: 4 }); + const p = report.patterns.find((x) => x.kind === "ISSUE_FARMING"); + expect(p).toBeDefined(); + expect(p!.issueRefs).toContain("hot-issue"); + }); + }); + + describe("SHARED_METADATA", () => { + it("fires when 3+ contributors share the same metadata value", () => { + const events = ["c1", "c2", "c3"].map((c) => + makeEvent(c, `issue-${c}`, "contributor_registered", { ipHash: "abc123" }), + ); + const report = svc.analyse(events); + const p = report.patterns.find((x) => x.kind === "SHARED_METADATA"); + expect(p).toBeDefined(); + expect(p!.contributorIds).toHaveLength(3); + }); + + it("does not fire when fewer than 3 contributors share metadata", () => { + const events = ["c1", "c2"].map((c) => + makeEvent(c, `issue-${c}`, "contributor_registered", { ipHash: "abc123" }), + ); + const report = svc.analyse(events); + expect(report.patterns.find((x) => x.kind === "SHARED_METADATA")).toBeUndefined(); + }); + }); + + it("reports analysedEventCount accurately", () => { + const events = [makeEvent("c1", "i1"), makeEvent("c2", "i2"), makeEvent("c3", "i3")]; + const report = svc.analyse(events); + expect(report.analysedEventCount).toBe(3); + }); + + it("never modifies the input events array", () => { + const events = [makeEvent("c1", "i1")]; + const original = JSON.stringify(events); + svc.analyse(events); + expect(JSON.stringify(events)).toBe(original); + }); +}); diff --git a/apps/api/src/modules/wave/coordinated-abuse-detection.service.ts b/apps/api/src/modules/wave/coordinated-abuse-detection.service.ts new file mode 100644 index 0000000..94a8d7d --- /dev/null +++ b/apps/api/src/modules/wave/coordinated-abuse-detection.service.ts @@ -0,0 +1,274 @@ +/** + * AI-210: Coordinated abuse pattern detection across contributors, issues, and appeals. + * + * Detects coordinated rule-breaking, contributor farming, and suspicious appeal + * clusters. Produces operator-review signals — never automated punishments. + * + * Explicit inputs → analyse(events[]) + * Explicit outputs → CoordinatedAbuseReport (patterns[], overallRisk, requiresHumanReview) + * Review boundary → requiresHumanReview=true for any risk above "low"; humans + * inspect and decide — the service never takes action itself. + */ + +import { Injectable, Logger } from "@nestjs/common"; + +// ── Input types ────────────────────────────────────────────────────────────── + +export type AbuseEventKind = + | "appeal_submitted" + | "issue_claimed" + | "contributor_registered" + | "duplicate_submission" + | "rapid_resubmission"; + +export interface AbuseEvent { + contributorId: string; + issueRef: string; + kind: AbuseEventKind; + /** ISO-8601 timestamp of the event. */ + occurredAt: string; + /** Optional metadata (IP hash, device fingerprint, etc.) */ + metadata?: Record; +} + +// ── Output types ───────────────────────────────────────────────────────────── + +export type AbusePatternKind = + | "VELOCITY_CLUSTER" // many events from one contributor in a short window + | "APPEAL_FLOODING" // contributor submitted many appeals in a short window + | "ISSUE_FARMING" // one issue claimed by many contributors in quick succession + | "SHARED_METADATA" // multiple contributors share suspicious metadata values + | "DUPLICATE_APPEAL_CLUSTER"; // identical appeal content from different contributors + +export type CoordinatedRiskLevel = "low" | "medium" | "high" | "critical"; + +export interface DetectedPattern { + kind: AbusePatternKind; + /** IDs of contributors involved. */ + contributorIds: string[]; + /** Issue refs involved, if applicable. */ + issueRefs: string[]; + /** Human-readable explanation safe for operator display. */ + description: string; + /** Numeric signal strength 0–100 (internal; not shown to contributors). */ + _signalStrength: number; +} + +export interface CoordinatedAbuseReport { + analysedEventCount: number; + patterns: DetectedPattern[]; + overallRisk: CoordinatedRiskLevel; + /** + * Always true when overallRisk is "medium" or above. + * Operators must review before taking any action. + */ + requiresHumanReview: boolean; + generatedAt: string; +} + +// ── Configurable thresholds (measurable, tunable) ──────────────────────────── + +export interface AbuseDetectionPolicy { + /** Max events per contributor within windowMs before VELOCITY_CLUSTER fires. */ + velocityEventLimit: number; + /** Max appeal submissions per contributor within windowMs before APPEAL_FLOODING fires. */ + appealFloodLimit: number; + /** Max unique contributors claiming the same issue within windowMs before ISSUE_FARMING fires. */ + issueFarmingContributorLimit: number; + /** Time window in milliseconds for all sliding-window checks. */ + windowMs: number; +} + +export const DEFAULT_ABUSE_DETECTION_POLICY: AbuseDetectionPolicy = { + velocityEventLimit: 10, + appealFloodLimit: 5, + issueFarmingContributorLimit: 4, + windowMs: 60 * 60 * 1000, // 1 hour +}; + +// ── Service ─────────────────────────────────────────────────────────────────── + +@Injectable() +export class CoordinatedAbuseDetectionService { + private readonly logger = new Logger(CoordinatedAbuseDetectionService.name); + + /** + * Analyse a batch of recent events for coordinated abuse patterns. + * Returns a report with detected patterns and an overall risk level. + * Never modifies state or triggers automated actions. + */ + analyse( + events: AbuseEvent[], + policy: Partial = {}, + ): CoordinatedAbuseReport { + const resolved: AbuseDetectionPolicy = { ...DEFAULT_ABUSE_DETECTION_POLICY, ...policy }; + const patterns: DetectedPattern[] = []; + + patterns.push( + ...this.detectVelocityClusters(events, resolved), + ...this.detectAppealFlooding(events, resolved), + ...this.detectIssueFarming(events, resolved), + ...this.detectSharedMetadata(events), + ); + + const overallRisk = this.toRiskLevel(patterns); + const requiresHumanReview = overallRisk !== "low"; + + const report: CoordinatedAbuseReport = { + analysedEventCount: events.length, + patterns, + overallRisk, + requiresHumanReview, + generatedAt: new Date().toISOString(), + }; + + if (requiresHumanReview) { + this.logger.warn( + JSON.stringify({ + event: "coordinated_abuse_flagged", + overallRisk, + patternCount: patterns.length, + patternKinds: patterns.map((p) => p.kind), + }), + ); + } + + return report; + } + + // ── Detectors ────────────────────────────────────────────────────────────── + + private detectVelocityClusters( + events: AbuseEvent[], + policy: AbuseDetectionPolicy, + ): DetectedPattern[] { + const byContributor = groupBy(events, (e) => e.contributorId); + const patterns: DetectedPattern[] = []; + + for (const [contributorId, contribEvents] of Object.entries(byContributor)) { + const withinWindow = filterWindow(contribEvents, policy.windowMs); + if (withinWindow.length >= policy.velocityEventLimit) { + patterns.push({ + kind: "VELOCITY_CLUSTER", + contributorIds: [contributorId], + issueRefs: unique(withinWindow.map((e) => e.issueRef)), + description: `Contributor ${contributorId} generated ${withinWindow.length} events within the detection window.`, + _signalStrength: Math.min(100, withinWindow.length * 8), + }); + } + } + + return patterns; + } + + private detectAppealFlooding( + events: AbuseEvent[], + policy: AbuseDetectionPolicy, + ): DetectedPattern[] { + const appealEvents = events.filter((e) => e.kind === "appeal_submitted"); + const byContributor = groupBy(appealEvents, (e) => e.contributorId); + const patterns: DetectedPattern[] = []; + + for (const [contributorId, contribAppeals] of Object.entries(byContributor)) { + const withinWindow = filterWindow(contribAppeals, policy.windowMs); + if (withinWindow.length >= policy.appealFloodLimit) { + patterns.push({ + kind: "APPEAL_FLOODING", + contributorIds: [contributorId], + issueRefs: unique(withinWindow.map((e) => e.issueRef)), + description: `Contributor ${contributorId} submitted ${withinWindow.length} appeals within the detection window.`, + _signalStrength: Math.min(100, withinWindow.length * 15), + }); + } + } + + return patterns; + } + + private detectIssueFarming( + events: AbuseEvent[], + policy: AbuseDetectionPolicy, + ): DetectedPattern[] { + const claimEvents = events.filter((e) => e.kind === "issue_claimed"); + const byIssue = groupBy(claimEvents, (e) => e.issueRef); + const patterns: DetectedPattern[] = []; + + for (const [issueRef, issueEvents] of Object.entries(byIssue)) { + const withinWindow = filterWindow(issueEvents, policy.windowMs); + const uniqueContributors = unique(withinWindow.map((e) => e.contributorId)); + if (uniqueContributors.length >= policy.issueFarmingContributorLimit) { + patterns.push({ + kind: "ISSUE_FARMING", + contributorIds: uniqueContributors, + issueRefs: [issueRef], + description: `Issue ${issueRef} was claimed by ${uniqueContributors.length} distinct contributors within the detection window.`, + _signalStrength: Math.min(100, uniqueContributors.length * 20), + }); + } + } + + return patterns; + } + + private detectSharedMetadata(events: AbuseEvent[]): DetectedPattern[] { + // Detect multiple contributors sharing an identical suspicious metadata value + // (e.g., same IP hash or device fingerprint). + const metaIndex = new Map>(); + + for (const e of events) { + if (!e.metadata) continue; + for (const [key, val] of Object.entries(e.metadata)) { + if (!val || typeof val !== "string") continue; + const signature = `${key}:${val}`; + if (!metaIndex.has(signature)) metaIndex.set(signature, new Set()); + metaIndex.get(signature)!.add(e.contributorId); + } + } + + const patterns: DetectedPattern[] = []; + + for (const [signature, contributors] of metaIndex.entries()) { + if (contributors.size >= 3) { + patterns.push({ + kind: "SHARED_METADATA", + contributorIds: Array.from(contributors), + issueRefs: [], + description: `${contributors.size} contributors share metadata value "${signature}".`, + _signalStrength: Math.min(100, contributors.size * 25), + }); + } + } + + return patterns; + } + + // ── Aggregation ──────────────────────────────────────────────────────────── + + private toRiskLevel(patterns: DetectedPattern[]): CoordinatedRiskLevel { + if (patterns.length === 0) return "low"; + const maxSignal = Math.max(...patterns.map((p) => p._signalStrength)); + if (maxSignal >= 80) return "critical"; + if (maxSignal >= 50) return "high"; + if (maxSignal >= 20) return "medium"; + return "low"; + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function groupBy(items: T[], key: (item: T) => string): Record { + return items.reduce>((acc, item) => { + const k = key(item); + (acc[k] ??= []).push(item); + return acc; + }, {}); +} + +function filterWindow(events: AbuseEvent[], windowMs: number): AbuseEvent[] { + const cutoff = Date.now() - windowMs; + return events.filter((e) => new Date(e.occurredAt).getTime() >= cutoff); +} + +function unique(arr: T[]): T[] { + return Array.from(new Set(arr)); +} diff --git a/apps/api/src/modules/wave/wave.module.ts b/apps/api/src/modules/wave/wave.module.ts index 0ff807d..638615c 100644 --- a/apps/api/src/modules/wave/wave.module.ts +++ b/apps/api/src/modules/wave/wave.module.ts @@ -1,5 +1,5 @@ /** - * WaveModule — bundles BE-207, BE-208, BE-211, BE-213 implementations. + * WaveModule — bundles BE-207, BE-208, BE-211, BE-213, AI-210 implementations. */ import { Module } from "@nestjs/common"; @@ -14,6 +14,7 @@ import { ReviewWindowService } from "./review-window.service.js"; import { ReviewWindowController } from "./review-window.controller.js"; import { AbuseRiskService } from "./abuse-risk.service.js"; import { AbuseRiskController } from "./abuse-risk.controller.js"; +import { CoordinatedAbuseDetectionService } from "./coordinated-abuse-detection.service.js"; @Module({ controllers: [AppealController, ReviewWindowController, AbuseRiskController], @@ -26,7 +27,8 @@ import { AbuseRiskController } from "./abuse-risk.controller.js"; AppealService, ReviewWindowService, AbuseRiskService, + CoordinatedAbuseDetectionService, ], - exports: [EligibilityService, ReviewWindowService, AbuseRiskService], + exports: [EligibilityService, ReviewWindowService, AbuseRiskService, CoordinatedAbuseDetectionService], }) export class WaveModule {} diff --git a/docs/ai-206-score-calibration.md b/docs/ai-206-score-calibration.md new file mode 100644 index 0000000..9f955d0 --- /dev/null +++ b/docs/ai-206-score-calibration.md @@ -0,0 +1,47 @@ +# AI-206: Policy-Aware Score Calibration for AI Appeal Outputs + +## Overview + +`ScoreCalibrationService` decouples raw model confidence (a float in `[0, 1]`) +from the policy thresholds that govern what happens next. This means fairness +parameters, review timing, and risk tolerances can be adjusted without touching +the model or rewriting the AI pipeline. + +## Explicit Inputs / Outputs + +| Direction | What it is | +|-----------|-----------| +| **Input** | `rawScore: number` — model confidence in `[0, 1]` | +| **Input** | `policy: Partial` — operator-tunable thresholds | +| **Output** | `CalibratedScore.band` — `auto_approve \| review \| auto_reject` | +| **Output** | `CalibratedScore.action` — recommended pipeline action | +| **Output** | `CalibratedScore.needsHumanReview` — human gate flag | +| **Output** | `CalibratedScore.appliedPolicy` — policy snapshot for audit trail | + +## Policy Knobs (`CalibrationPolicy`) + +| Field | Default | Purpose | +|-------|---------|---------| +| `approveThreshold` | `0.80` | Score at or above → `auto_approve` | +| `rejectThreshold` | `0.25` | Score at or below → `auto_reject` | +| `humanReviewThreshold` | `0.70` | Confidence below this → `needsHumanReview=true` | +| `biasCorrectionFactor` | `1.0` | Multiply score before band assignment (> 1 = lenient) | + +## Review Boundary + +`needsHumanReview` is always `true` when `confidence < humanReviewThreshold`. +The caller must honour this flag before acting on `action`. The pipeline must +**not** auto-act when `needsHumanReview` is true. + +## Measurability + +Every `CalibratedScore` includes: +- `rawScore` — the unmodified model output (audit trail) +- `appliedPolicy` — the full policy snapshot used (reproducibility) + +Operators can replay any historical score against updated policy values to +measure the effect of a threshold change before promoting it to production. + +## File Location + +`apps/api/src/modules/appeal-decisions/score-calibration.service.ts` diff --git a/docs/ai-208-shadow-mode.md b/docs/ai-208-shadow-mode.md new file mode 100644 index 0000000..9215b10 --- /dev/null +++ b/docs/ai-208-shadow-mode.md @@ -0,0 +1,54 @@ +# AI-208: Shadow Mode for AI Appeal Review Logic + +## Overview + +`ShadowModeService` provides a safe path for evaluating candidate model changes +against live-like appeal traffic without affecting any outcomes. The candidate +scorer runs in a fire-and-observe loop: results are logged, never acted on. + +## How it works + +``` +live appeal request + │ + ├─► live scorer ─► outcome applied (DB write, notification, etc.) + │ + └─► ShadowModeService.scoreShadow(request, candidateScorer) + │ + ├─► candidate scorer runs (errors caught; live path unaffected) + ├─► result logged as structured JSON { event: "shadow_score", … } + └─► diverged=true logged as WARN if bands differ +``` + +## Explicit Inputs / Outputs + +| Direction | What it is | +|-----------|-----------| +| **Input** | `ShadowScoreRequest.liveScore` — current model's score | +| **Input** | `ShadowScoreRequest.features` — feature payload forwarded to candidate | +| **Input** | `candidateScorer: CandidateScorerFn` — the new logic under test | +| **Output** | `ShadowScoreResult.candidateScore` — what the candidate would have scored | +| **Output** | `ShadowScoreResult.diverged` — true when bands differ (human review signal) | +| **Output** | `ShadowScoreResult.delta` — magnitude of score difference | + +## Review Boundary + +`diverged=true` means the candidate places the appeal in a different policy band +than the live model. These cases must be inspected by a maintainer before the +candidate is promoted. + +**The shadow path never writes to the database, sends notifications, or changes +appeal state.** It is safe to run on any live traffic. + +## Promotion Checklist + +Before promoting a candidate to production: + +1. Run shadow mode for ≥ 48 hours of live traffic. +2. Review all `diverged=true` log lines — confirm the candidate's band is correct. +3. Check P95 `latencyMs` is acceptable. +4. Record findings as a comment on the AI-208 issue before merging. + +## File Location + +`apps/api/src/modules/appeal-decisions/shadow-mode.service.ts` diff --git a/docs/ai-209-rollout-controls.md b/docs/ai-209-rollout-controls.md new file mode 100644 index 0000000..6600ff9 --- /dev/null +++ b/docs/ai-209-rollout-controls.md @@ -0,0 +1,53 @@ +# AI-209: Model Rollback and Safe Rollout Controls for Appeal Automation + +## Overview + +`ModelRolloutService` provides a lightweight, in-process rollout controller with +three operating modes. AI behaviour can be pinned at a stable version, promoted +to full traffic, or gradually canary'd — and rolled back in one API call. + +## Rollout Modes + +| Mode | Behaviour | +|------|-----------| +| `pinned` | All requests use `stableVersion`. Safe baseline; no candidate exposure. | +| `canary` | `canaryPercent`% of requests use `activeVersion`; rest use `stableVersion`. Deterministic per `requestId`. | +| `full` | All requests use `activeVersion`. Only after canary validation. | + +## Rollback + +``` +modelRolloutService.rollback() +``` + +Restores the previous `RolloutConfig` instantly. The service stores exactly one +level of previous config so a single call always undoes the last `setRollout`. +Rollback is a no-op if there is no previous config. + +## Explicit Inputs / Outputs + +| Direction | What it is | +|-----------|-----------| +| **Input** | `setRollout(config: RolloutConfig)` — operator sets mode, versions, canary% | +| **Input** | `resolveModel(requestId)` — per-request routing decision | +| **Output** | `RolloutResolution.modelVersion` — which version to use | +| **Output** | `RolloutResolution.reason` — human-readable routing explanation | +| **Output** | `ModelRolloutState` — current + previous config with timestamp | + +## Review Boundary + +Canary mode is the human review gate. Promote from `canary` → `full` only after: + +1. Reviewing divergence logs from `ShadowModeService` (AI-208). +2. Confirming fairness metrics are stable. +3. No increase in human override rate (from `AppealDecisionsService.humanOverride` flag). + +## Measurability + +Every `setRollout` and `rollback` emits a structured log event +(`rollout_config_updated`, `rollout_rolled_back`) for metric pipelines and +operator audit. + +## File Location + +`apps/api/src/modules/appeal-decisions/model-rollout.service.ts` diff --git a/docs/ai-210-coordinated-abuse-detection.md b/docs/ai-210-coordinated-abuse-detection.md new file mode 100644 index 0000000..04f97a5 --- /dev/null +++ b/docs/ai-210-coordinated-abuse-detection.md @@ -0,0 +1,46 @@ +# AI-210: Coordinated Abuse Pattern Detection + +## Overview + +`CoordinatedAbuseDetectionService` spots coordinated rule-breaking, contributor +farming, and suspicious appeal clusters across the platform. It produces +operator-review signals and never takes automated action. + +## Detected Patterns + +| Pattern | Trigger | +|---------|---------| +| `VELOCITY_CLUSTER` | One contributor generates ≥ `velocityEventLimit` events in the window | +| `APPEAL_FLOODING` | One contributor submits ≥ `appealFloodLimit` appeals in the window | +| `ISSUE_FARMING` | One issue is claimed by ≥ `issueFarmingContributorLimit` distinct contributors in the window | +| `SHARED_METADATA` | ≥ 3 contributors share an identical metadata value (e.g., IP hash) | + +## Explicit Inputs / Outputs + +| Direction | What it is | +|-----------|-----------| +| **Input** | `AbuseEvent[]` — recent events with contributor ID, issue ref, kind, timestamp | +| **Input** | `Partial` — tunable thresholds | +| **Output** | `CoordinatedAbuseReport.patterns[]` — each detected cluster with contributors/issues | +| **Output** | `CoordinatedAbuseReport.overallRisk` — `low \| medium \| high \| critical` | +| **Output** | `CoordinatedAbuseReport.requiresHumanReview` — always true when risk > low | + +## Review Boundary + +The service **never writes to the database, bans contributors, or changes appeal +state.** Every `requiresHumanReview=true` report must be inspected by an operator +before any consequence is applied. Automated punishment is explicitly excluded. + +## Measurability and Tuning + +All thresholds live in `AbuseDetectionPolicy` (default exported as +`DEFAULT_ABUSE_DETECTION_POLICY`). Operators can change any value at runtime by +passing `Partial` to `analyse()` — no code change needed. + +Internal `_signalStrength` scores (0–100) drive `overallRisk` but are stripped +from the public `CoordinatedAbuseReportResponse` contract so contributors never +see detection internals. + +## File Location + +`apps/api/src/modules/wave/coordinated-abuse-detection.service.ts` diff --git a/packages/api-contracts/src/index.ts b/packages/api-contracts/src/index.ts index ced574f..b508f86 100644 --- a/packages/api-contracts/src/index.ts +++ b/packages/api-contracts/src/index.ts @@ -403,6 +403,47 @@ export interface AppealTimingResult { appealDeadline: string; } +// ── AI-210: Coordinated abuse pattern detection ─────────────────────────────── + +export type AbuseEventKind = + | "appeal_submitted" + | "issue_claimed" + | "contributor_registered" + | "duplicate_submission" + | "rapid_resubmission"; + +export type AbusePatternKind = + | "VELOCITY_CLUSTER" + | "APPEAL_FLOODING" + | "ISSUE_FARMING" + | "SHARED_METADATA" + | "DUPLICATE_APPEAL_CLUSTER"; + +export type CoordinatedRiskLevel = "low" | "medium" | "high" | "critical"; + +export interface AbuseEventPayload { + contributorId: string; + issueRef: string; + kind: AbuseEventKind; + occurredAt: string; + metadata?: Record; +} + +export interface DetectedPatternSummary { + kind: AbusePatternKind; + contributorIds: string[]; + issueRefs: string[]; + description: string; +} + +export interface CoordinatedAbuseReportResponse { + analysedEventCount: number; + patterns: DetectedPatternSummary[]; + overallRisk: CoordinatedRiskLevel; + requiresHumanReview: boolean; + generatedAt: string; +} + // ── Budget Accounting (BE-201, BE-202, BE-203, BE-204) ─────────────────────────── export type BudgetEventType = @@ -481,6 +522,106 @@ export interface GetBudgetMetricsQuery { includeEvents?: boolean; } +// ── AI-201: Prompt & Policy Registry ───────────────────────────────────────── + +export type PromptPolicyKind = "prompt" | "policy" | "threshold" | "model_version"; + +export interface PromptPolicyEntrySummary { + id: string; + key: string; + version: number; + kind: PromptPolicyKind; + content: string; + isActive: boolean; + publishedBy: string | null; + notes: string | null; + createdAt: string; +} + +export interface CreatePromptPolicyPayload { + key: string; + kind: PromptPolicyKind; + content: string; + notes?: string; + publishedBy?: string; +} + +export interface ActivatePromptPolicyPayload { + publishedBy?: string; +} + +// ── AI-202: Review Context AI Preprocessor ─────────────────────────────────── + +export type ReviewSignal = "strong_approval" | "approval" | "neutral" | "changes_requested" | "rejected"; + +export interface AIReadyReviewContext { + pullRequestId: string; + repositoryId: string; + signal: ReviewSignal; + confidence: number; + reviewerCount: number; + approvalCount: number; + changesRequestedCount: number; + totalComments: number; + totalRequestedChanges: number; + latestMergeStatus: string; + humanOverrideRecommended: boolean; + preprocessedAt: string; +} + +// ── INFRA-213: Data Retention ───────────────────────────────────────────────── + +export interface RetentionPolicy { + resource: string; + retentionDays: number; + description: string; +} + +export interface RetentionRunResult { + resource: string; + deletedCount: number; + cutoffDate: string; + dryRun: boolean; +} + +export interface RetentionRunSummary { + ranAt: string; + dryRun: boolean; + results: RetentionRunResult[]; + totalDeleted: number; +} + +// ── INFRA-214: Feature Flag & Config Distribution ──────────────────────────── + +export type WaveFeatureKey = + | "wave5_ai_appeals" + | "wave5_budget_accounting" + | "wave5_contributor_verification" + | "wave5_point_ledger" + | "wave5_notifications" + | "wave5_review_context" + | "wave5_data_retention"; + +export interface WaveFeatureFlag { + key: WaveFeatureKey; + enabled: boolean; + rolloutPercent: number; + overriddenBy: string | null; + updatedAt: string; +} + +export interface WaveRuntimeControls { + flags: WaveFeatureFlag[]; + version: number; + generatedAt: string; +} + +export interface SetFeatureFlagPayload { + enabled: boolean; + rolloutPercent?: number; + overriddenBy?: string; +} + export interface SetOrganizationBudgetPayload { organizationId: string; capPoints: number;