diff --git a/.loopover.yml.example b/.loopover.yml.example index 9ab0a969a2..06446800af 100644 --- a/.loopover.yml.example +++ b/.loopover.yml.example @@ -1266,3 +1266,14 @@ settings: # env var, which stays the fallback when absent. # upstreamDriftIssues: # enabled: true # Bool. Default: false (the env var decides instead). + +# Opt-in federated fleet intelligence export (#1970): packages this instance's OWN calibration signals into a +# signed, anonymized bundle an operator can choose to hand to a peer (or to a collector they run). Exports +# AGGREGATE figures only -- gate precision, reversal/slop/copycat rates over a window -- never source code, +# diffs, GitHub logins, repo names, PR ids, or raw gate reason text. Operator-level, not per-repo, so only +# meaningful on the loopover self-repo's own manifest. OFF unless you explicitly enable it here: with the block +# absent nothing is bundled and nothing leaves the instance. Distinct from the always-on Orb telemetry (#1255, +# suppressed by ORB_AIR_GAP), which streams per-PR events UP to loopover's central collector -- this one is +# peer-to-peer and assumes no central service at all. +# federatedIntelligence: +# enabled: true # Bool. Default: false (nothing is exported). diff --git a/config/examples/loopover.full.yml b/config/examples/loopover.full.yml index a7323bad68..5a4fefb295 100644 --- a/config/examples/loopover.full.yml +++ b/config/examples/loopover.full.yml @@ -1280,3 +1280,14 @@ settings: # env var, which stays the fallback when absent. # upstreamDriftIssues: # enabled: true # Bool. Default: false (the env var decides instead). + +# Opt-in federated fleet intelligence export (#1970): packages this instance's OWN calibration signals into a +# signed, anonymized bundle an operator can choose to hand to a peer (or to a collector they run). Exports +# AGGREGATE figures only -- gate precision, reversal/slop/copycat rates over a window -- never source code, +# diffs, GitHub logins, repo names, PR ids, or raw gate reason text. Operator-level, not per-repo, so only +# meaningful on the loopover self-repo's own manifest. OFF unless you explicitly enable it here: with the block +# absent nothing is bundled and nothing leaves the instance. Distinct from the always-on Orb telemetry (#1255, +# suppressed by ORB_AIR_GAP), which streams per-PR events UP to loopover's central collector -- this one is +# peer-to-peer and assumes no central service at all. +# federatedIntelligence: +# enabled: true # Bool. Default: false (nothing is exported). diff --git a/packages/loopover-engine/src/focus-manifest.ts b/packages/loopover-engine/src/focus-manifest.ts index 5408b2debe..1572a92fda 100644 --- a/packages/loopover-engine/src/focus-manifest.ts +++ b/packages/loopover-engine/src/focus-manifest.ts @@ -424,6 +424,23 @@ export type FocusManifestUpstreamDriftIssuesConfig = { enabled: boolean; }; +/** + * Config-as-code opt-in for the federated fleet intelligence export (#1970), declared under + * `federatedIntelligence:`. Gates buildFederatedBundle (src/orb/federated-bundle.ts), which packages this + * instance's own anonymized calibration signals into a signed bundle an operator can hand to a peer -- like + * `draftFlow:`/`upstreamDriftIssues:` above, this is read from the loopover self-repo's own manifest, since + * exporting a deployment's calibration data is operator-level (fleet-wide), not per-contributor-repo. + * Mirrors `upstreamDriftIssues:` exactly: no DB-backed counterpart, so the parsed value (or the default + * below when unset) IS the effective value. Unlike those two it overrides no env flag -- there is no + * federated env var; not present ⇒ disabled ⇒ nothing is bundled and no network call is made, byte-identical + * to before this override existed. (ORB_AIR_GAP gates the separate, always-on #1255 orb telemetry path in + * src/selfhost/orb-collector.ts and is unrelated to this opt-in.) + */ +export type FocusManifestFederatedIntelligenceConfig = { + present: boolean; + enabled: boolean; +}; + /** * Generic repository-settings override declared in `.loopover.yml` under `settings:`. A partial of * {@link RepositorySettings} — every behaviour a maintainer can toggle in the dashboard can be set here @@ -1009,6 +1026,7 @@ export type FocusManifest = { publicStats: FocusManifestPublicStatsConfig; draftFlow: FocusManifestDraftFlowConfig; upstreamDriftIssues: FocusManifestUpstreamDriftIssuesConfig; + federatedIntelligence: FocusManifestFederatedIntelligenceConfig; warnings: string[]; }; @@ -1170,6 +1188,11 @@ const EMPTY_UPSTREAM_DRIFT_ISSUES_CONFIG: FocusManifestUpstreamDriftIssuesConfig enabled: false, }; +const EMPTY_FEDERATED_INTELLIGENCE_CONFIG: FocusManifestFederatedIntelligenceConfig = { + present: false, + enabled: false, +}; + const EMPTY_MANIFEST: FocusManifest = { present: false, source: "none", @@ -1193,6 +1216,7 @@ const EMPTY_MANIFEST: FocusManifest = { publicStats: { ...EMPTY_PUBLIC_STATS_CONFIG }, draftFlow: { ...EMPTY_DRAFT_FLOW_CONFIG }, upstreamDriftIssues: { ...EMPTY_UPSTREAM_DRIFT_ISSUES_CONFIG }, + federatedIntelligence: { ...EMPTY_FEDERATED_INTELLIGENCE_CONFIG }, warnings: [], }; @@ -1229,6 +1253,7 @@ function emptyManifest(source: FocusManifestSource, warnings: string[] = []): Fo publicStats: { ...EMPTY_PUBLIC_STATS_CONFIG }, draftFlow: { ...EMPTY_DRAFT_FLOW_CONFIG }, upstreamDriftIssues: { ...EMPTY_UPSTREAM_DRIFT_ISSUES_CONFIG }, + federatedIntelligence: { ...EMPTY_FEDERATED_INTELLIGENCE_CONFIG }, }; } @@ -2038,6 +2063,30 @@ export function upstreamDriftIssuesConfigToJson(config: FocusManifestUpstreamDri return { enabled: config.enabled }; } +/** + * Parse the optional `federatedIntelligence:` mapping (#1970). Mirrors {@link parseUpstreamDriftIssuesConfig} + * exactly -- `enabled` is the only field, defaulting to false, so the parsed value IS the effective value and + * an absent block leaves the federated export off. + */ +function parseFederatedIntelligenceConfig(value: JsonValue | undefined, warnings: string[]): FocusManifestFederatedIntelligenceConfig { + if (value === undefined || value === null) return { ...EMPTY_FEDERATED_INTELLIGENCE_CONFIG }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push('Manifest field "federatedIntelligence" must be a mapping; ignoring it.'); + return { ...EMPTY_FEDERATED_INTELLIGENCE_CONFIG }; + } + const record = value as Record; + const enabled = normalizeOptionalBoolean(record.enabled, "federatedIntelligence.enabled", warnings) ?? false; + return { present: true, enabled }; +} + +/** Serialize a federatedIntelligence config back into the parse-compatible shape so a cached snapshot + * round-trips through {@link parseFederatedIntelligenceConfig} unchanged. Returns null when nothing is + * configured. */ +export function federatedIntelligenceConfigToJson(config: FocusManifestFederatedIntelligenceConfig): JsonValue { + if (!config.present) return null; + return { enabled: config.enabled }; +} + function normalizeOptionalEnum(value: JsonValue | undefined, field: string, allowed: readonly T[], warnings: string[]): T | null { if (value === undefined || value === null) return null; if (typeof value === "string" && (allowed as readonly string[]).includes(value)) return value as T; @@ -3385,6 +3434,7 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): publicStats: parsePublicStatsConfig(record.publicStats, warnings), draftFlow: parseDraftFlowConfig(record.draftFlow, warnings), upstreamDriftIssues: parseUpstreamDriftIssuesConfig(record.upstreamDriftIssues, warnings), + federatedIntelligence: parseFederatedIntelligenceConfig(record.federatedIntelligence, warnings), warnings, }; if ( @@ -3407,7 +3457,8 @@ export function parseFocusManifest(raw: unknown, source?: FocusManifestSource): !manifest.ops.present && !manifest.publicStats.present && !manifest.draftFlow.present && - !manifest.upstreamDriftIssues.present + !manifest.upstreamDriftIssues.present && + !manifest.federatedIntelligence.present ) { warnings.push("Manifest contained no recognized focus fields; falling back to deterministic signals."); manifest.present = false; diff --git a/packages/loopover-engine/src/index.ts b/packages/loopover-engine/src/index.ts index 65f3cb2df1..6d551d6ddd 100644 --- a/packages/loopover-engine/src/index.ts +++ b/packages/loopover-engine/src/index.ts @@ -757,6 +757,7 @@ export { publicStatsConfigToJson, draftFlowConfigToJson, upstreamDriftIssuesConfigToJson, + federatedIntelligenceConfigToJson, settingsOverrideToJson, MAX_FOCUS_MANIFEST_BYTES, CONVERGED_FEATURE_KEYS, @@ -795,6 +796,7 @@ export { type FocusManifestPublicStatsConfig, type FocusManifestDraftFlowConfig, type FocusManifestUpstreamDriftIssuesConfig, + type FocusManifestFederatedIntelligenceConfig, type FocusManifestSettings, type FocusManifestSource, type LinkedIssueSatisfactionMode, diff --git a/src/orb/analytics.ts b/src/orb/analytics.ts index dad734f18a..7e6450f3bb 100644 --- a/src/orb/analytics.ts +++ b/src/orb/analytics.ts @@ -26,14 +26,17 @@ // different (how often THIS instance's own PRs lose a local collision) than "identities farming wins," so no // proxy for it is implemented — a misleading proxy would be worse than none. -const MIN_DECIDED = 5; // an instance needs at least this many decided PRs to count toward the fleet median +// Exported so the federated bundle export (#1970, src/orb/federated-bundle.ts) gates its own published +// precision on the SAME volume bar the fleet median uses — a bundle must not advertise a precision the fleet +// would refuse to count. +export const MIN_DECIDED = 5; // an instance needs at least this many decided PRs to count toward the fleet median const OUTLIER_BAND = 0.25; // |instance precision − fleet median| beyond this flags the instance const GAMING_VOLUME_MULTIPLIER = 2; // an instance's decided count more than this many times the fleet median const GAMING_PRECISION_BAND = OUTLIER_BAND; // mergePrecision this far ABOVE the fleet median (one-sided) const GAMING_REVERSAL_RATIO = 0.5; // reversalRate below this fraction of the fleet median /** Per-instance confusion-matrix cell as stored. */ -interface Cell { +export interface Cell { instance_id: string; verdict: string | null; outcome: string; @@ -93,7 +96,7 @@ function median(xs: number[]): number | null { return s.length % 2 === 0 ? (s[mid - 1]! + s[mid]!) / 2 : s[mid]!; } -function percentile(sorted: number[], p: number): number | null { +export function percentile(sorted: number[], p: number): number | null { if (sorted.length === 0) return null; // Nearest-rank: the p-th percentile is the value at 1-based rank ceil(p/100 * N), i.e. index // ceil(p/100 * N) - 1. `Math.floor(p/100 * N)` overshot by one rank whenever p/100 * N was an @@ -104,8 +107,13 @@ function percentile(sorted: number[], p: number): number | null { } /** Fold the confusion-matrix cells for one instance into accuracy metrics (reversals count as the gate - * being wrong: a reverted merge is a false positive; a reopened close is a false negative). */ -function foldInstance(instanceId: string, cells: Cell[]): InstanceMetrics { + * being wrong: a reverted merge is a false positive; a reopened close is a false negative). + * + * Exported for the federated bundle export (#1970, src/orb/federated-bundle.ts): a bundle publishes this + * instance's own precision for #6481 to compare against the peer median computed here, so both sides MUST use + * this one definition — reimplementing it there would silently make the comparison apples-to-oranges. Callers + * must pass a non-empty `cells` (reversalRate divides by the decided total). */ +export function foldInstance(instanceId: string, cells: Cell[]): InstanceMetrics { let wouldMerge = 0, mergeConfirmed = 0, mergeFalse = 0; let wouldClose = 0, closeConfirmed = 0, closeFalse = 0; let reversals = 0, decided = 0; diff --git a/src/orb/federated-bundle.ts b/src/orb/federated-bundle.ts new file mode 100644 index 0000000000..7deaab3997 --- /dev/null +++ b/src/orb/federated-bundle.ts @@ -0,0 +1,264 @@ +// LoopOver federated fleet intelligence (#1970) — OPT-IN, peer-to-peer calibration bundle EXPORT (#6478). +// +// This is the EXPORT side only: it packages a subset of this instance's own local calibration data into a +// signed, anonymized bundle an operator can choose to hand to a peer. It performs NO network call — the +// transport (push/pull against an operator-configured collector) is #6479, the receiving/trust-gating side is +// #6480, and the key-trust scheme is #6477's design (see the TODO on the signing key below). +// +// NOT the same thing as the #1255 orb export (src/selfhost/orb-collector.ts:155). That path is deliberately +// distinct on five axes, and this module exists precisely because none of them can be retrofitted onto it: +// 1. TRIGGER — #1255 is ALWAYS ON once the App is configured (orb-collector.ts:6-7 "there is no opt-out +// flag"); its only suppressor is the ORB_AIR_GAP env var (orb-collector.ts:157). This is +// OPT-IN via `.loopover.yml` config-as-code, default OFF. +// 2. DESTINATION — #1255 POSTs UP to loopover's central hosted collector (orb-collector.ts:168, +// https://api.loopover.ai/v1/orb/ingest). A federated bundle goes to a PEER or an operator's +// own collector; no central service is assumed anywhere. +// 3. GRANULARITY — #1255 streams a watermark-paginated PER-PR event stream. This is a single AGGREGATE +// calibration snapshot over a window. +// 4. PRIVACY FLOOR— #1255 still carries HMAC'd repo_hash/pr_hash per event (orb-collector.ts:187-188). This +// carries ZERO identifiers, not even hashed ones: the aggregate query below never SELECTs +// an identifying column at all, so the floor is structural rather than a filtering step. +// 5. CONTENT — #1255 exports raw per-PR verdict/outcome/reversal. This exports aggregate calibration +// precision plus bucketed slop/copycat rates. +// +// The precision math is REUSED from the fleet analytics (foldInstance) rather than reimplemented, deliberately: +// #6481 renders "this instance's gate precision vs the peer median", so a bundle's mergePrecision must be +// computed by the exact same confusion-matrix definition the fleet median uses, or the comparison is +// apples-to-oranges. Same reason MIN_DECIDED gates the published precision here. +import { createHmac } from "node:crypto"; +import { bucketReasonCode, cycleTimeMs, getOrCreateAnonSecret, instanceId } from "../selfhost/orb-collector"; +import { foldInstance, MIN_DECIDED, percentile, type Cell as FleetCell } from "./analytics"; +import type { FocusManifest } from "../signals/focus-manifest"; + +/** Bumped whenever the bundle's field set or semantics change, so a receiving instance (#6480) can reject or + * upgrade a bundle it does not understand instead of silently misreading it. */ +export const FEDERATED_BUNDLE_SCHEMA_VERSION = 1; + +/** Default calibration window. Mirrors computeFleetAnalytics' 90-day default and 365-day clamp so a bundle's + * window is directly comparable to the fleet's. */ +const DEFAULT_WINDOW_DAYS = 90; +const MAX_WINDOW_DAYS = 365; + +/** + * The signed payload of a federated calibration bundle: every field except the signature itself. + * + * EVERY FIELD IS ENUMERATED HERE AND IS AGGREGATE-ONLY. There is deliberately no source code, no diff, no + * GitHub login, no repo name, no PR number/id, no commit SHA, no raw gate reason text and no per-PR row — the + * query that feeds this never selects any of them. `instanceId` is the same opaque, HMAC-derived handle the + * existing orb pipeline already uses (src/selfhost/orb-collector.ts:59), not an identity. + * + * Adding a field here is a deliberate privacy decision: the schema test asserts this exact key set and fails + * if it changes, so a new field cannot land without review. + */ +export interface FederatedSignalBundleBody { + /** Schema contract version — see FEDERATED_BUNDLE_SCHEMA_VERSION. */ + schemaVersion: number; + /** Opaque per-instance handle (no PII) — reused from the orb pipeline so peers can dedup bundles. */ + instanceId: string; + /** ISO timestamp this bundle was built. */ + generatedAt: string; + /** Length of the calibration window, so peers only median equal-length windows. */ + windowDays: number; + /** Resolved PRs in-window that the gate decided. Drives the MIN_DECIDED eligibility bar below. */ + decided: number; + /** P(merged & not reverted | gate said merge). Null until `decided` >= MIN_DECIDED. */ + mergePrecision: number | null; + /** P(closed & not reopened | gate said close). Null until `decided` >= MIN_DECIDED. */ + closePrecision: number | null; + /** P(closed or reverted | gate said merge) — the gate approved and was wrong. Null until eligible. */ + fpRate: number | null; + /** P(merged or reopened | gate said close) — the gate blocked and was wrong. Null until eligible. */ + fnRate: number | null; + /** Share of decided PRs a human reversed. 0 when nothing was decided. */ + reversalRate: number; + /** Median gate-decision → resolution latency. Null until eligible or when no cycle time is measurable. */ + cycleP50Ms: number | null; + /** p95 gate-decision → resolution latency. Null until eligible or when no cycle time is measurable. */ + cycleP95Ms: number | null; + /** Share of decided PRs whose gate reason bucketed to "slop_advisory" — an aggregate rate, never PR text. */ + slopRate: number; + /** Share of decided PRs whose gate reason bucketed to "duplicate_risk" — an aggregate rate. Deliberately + * NOT a per-shingle hash list or cluster id: no persisted shingle source exists, and duplicate-cluster + * winner linkage is an explicit non-goal of this pipeline (see src/orb/analytics.ts's OUT OF SCOPE note). */ + copycatRate: number; +} + +/** A federated calibration bundle: the signed body plus its detached HMAC. */ +export interface FederatedSignalBundle extends FederatedSignalBundleBody { + /** Hex HMAC-SHA256 over canonicalizeFederatedBundleBody(body) — see signFederatedBundle. */ + signature: string; +} + +/** One resolved-PR row of this instance's own local ground truth. Carries NO identifier by construction — see + * LOCAL_CALIBRATION_QUERY, which never selects project/target_id. */ +interface LocalRow { + verdict: string | null; + reasoncode: string | null; + decided_at: string; + outcome: string; + outcome_at: string; + reverted: number; + reopened: number; +} + +// Latest gate_decision + latest pr_outcome per target_id, plus any reversal, restricted to a window. Mirrors +// FLEET_QUERY's CTE shape (src/selfhost/orb-collector.ts:107) so it stays portable across the SQLite self-host +// and Postgres backends (window functions + CASE, no SQLite-only bare-column-with-MAX). +// +// The privacy floor is enforced HERE: the projection selects only verdict/outcome/reversal/timing. `project` +// and `target_id` are joined on but never selected, so no identifier can reach a bundle even by mistake. +const LOCAL_CALIBRATION_QUERY = ` + WITH gd AS ( + SELECT target_id, decision AS verdict, summary AS reasoncode, created_at AS decided_at, + ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY created_at DESC) AS rn + FROM review_audit + WHERE event_type = 'gate_decision' AND decision IS NOT NULL AND source = 'gittensory-native' + ), + po AS ( + SELECT target_id, decision AS outcome, created_at AS outcome_at, + ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY created_at DESC) AS rn + FROM review_audit + WHERE event_type = 'pr_outcome' AND decision IS NOT NULL + ), + rev AS ( + SELECT target_id, + MAX(CASE WHEN event_type = 'reversal_reverted' THEN 1 ELSE 0 END) AS reverted, + MAX(CASE WHEN event_type = 'reversal_reopened' THEN 1 ELSE 0 END) AS reopened + FROM review_audit + WHERE event_type IN ('reversal_reverted', 'reversal_reopened') + GROUP BY target_id + ) + SELECT gd.verdict AS verdict, gd.reasoncode AS reasoncode, gd.decided_at AS decided_at, + po.outcome AS outcome, po.outcome_at AS outcome_at, + COALESCE(rev.reverted, 0) AS reverted, COALESCE(rev.reopened, 0) AS reopened + FROM gd + JOIN po ON gd.target_id = po.target_id + LEFT JOIN rev ON gd.target_id = rev.target_id + WHERE gd.rn = 1 AND po.rn = 1 AND po.outcome_at >= ?`; + +/** + * Canonical JSON for signing: the body's keys are emitted in this exact, documented order so a receiving + * instance (#6480) can recompute the HMAC byte-for-byte without depending on JS key-insertion order. + */ +export function canonicalizeFederatedBundleBody(body: FederatedSignalBundleBody): string { + return JSON.stringify([ + ["schemaVersion", body.schemaVersion], + ["instanceId", body.instanceId], + ["generatedAt", body.generatedAt], + ["windowDays", body.windowDays], + ["decided", body.decided], + ["mergePrecision", body.mergePrecision], + ["closePrecision", body.closePrecision], + ["fpRate", body.fpRate], + ["fnRate", body.fnRate], + ["reversalRate", body.reversalRate], + ["cycleP50Ms", body.cycleP50Ms], + ["cycleP95Ms", body.cycleP95Ms], + ["slopRate", body.slopRate], + ["copycatRate", body.copycatRate], + ]); +} + +/** + * HMAC-sign a bundle body so a receiving instance can verify it was not tampered with in transit. + * + * TODO(#6477): the KEY-TRUST scheme (how a peer establishes/rotates the key it verifies against) is #6477's + * design decision and is deliberately NOT invented here. Until it lands, the signing key is this instance's + * existing dedicated anonymization secret (getOrCreateAnonSecret) as a placeholder: it makes the bundle + * tamper-evident to anyone who already holds the key, but it does NOT yet establish peer trust. #6480 (the + * import side) is explicitly blocked on #6477 for exactly that reason. + */ +export function signFederatedBundle(body: FederatedSignalBundleBody, key: string): string { + return createHmac("sha256", key).update(canonicalizeFederatedBundleBody(body)).digest("hex"); +} + +/** Is the federated export opted in for this deployment? Absent block ⇒ false ⇒ byte-identical behavior. */ +export function isFederatedIntelligenceEnabled(manifest: Pick | null | undefined): boolean { + return manifest?.federatedIntelligence?.enabled === true; +} + +/** + * Build this instance's signed, anonymized federated calibration bundle. + * + * Returns null — reading nothing and calling nothing — unless the operator has explicitly opted in via + * `federatedIntelligence.enabled: true` in `.loopover.yml`. An instance that has not opted in is byte-identical + * to before this module existed: no DB read, no network call (this module never makes one at all), no side + * effect. + * + * FAIL-SAFE: any error while building degrades to null. This is a pure library that the gate never awaits, so + * a failure here can never alter review/merge behavior — but the catch makes that guarantee explicit rather + * than incidental. + */ +export async function buildFederatedBundle( + manifest: Pick | null | undefined, + db: D1Database, + opts: { windowDays?: number; now?: number } = {}, +): Promise { + if (!isFederatedIntelligenceEnabled(manifest)) return null; + + try { + const windowDays = + Number.isFinite(opts.windowDays) && (opts.windowDays as number) > 0 + ? Math.min(opts.windowDays as number, MAX_WINDOW_DAYS) + : DEFAULT_WINDOW_DAYS; + const now = Number.isFinite(opts.now) ? (opts.now as number) : Date.now(); + // Date-only cutoff, like computeFleetAnalytics — compares correctly whether created_at is ISO ('…T…Z') or + // SQLite's CURRENT_TIMESTAMP space format ('YYYY-MM-DD HH:MM:SS'). + const cutoff = new Date(now - windowDays * 86_400_000).toISOString().slice(0, 10); + + const secret = await getOrCreateAnonSecret(db); + const instance = instanceId(secret); + + const { results } = await db.prepare(LOCAL_CALIBRATION_QUERY).bind(cutoff).all(); + const rows = results ?? []; + const decided = rows.length; + + // Reuse the fleet's confusion-matrix fold so this instance's precision is defined identically to the peer + // median it will be compared against (#6481). One cell per row; foldInstance sums their `n`. + const cells: FleetCell[] = rows.map((r) => ({ + instance_id: instance, + verdict: r.verdict, + outcome: r.outcome, + reversal_flag: r.reverted ? "reverted" : r.reopened ? "reopened" : "none", + n: 1, + })); + const metrics = decided > 0 ? foldInstance(instance, cells) : null; + + // Below MIN_DECIDED the precision figures are noise, and the fleet would not count them toward a median + // anyway (src/orb/analytics.ts's `eligible` filter) — so publish them as null rather than as a number a + // peer would wrongly average in. + const eligible = decided >= MIN_DECIDED; + + const cycleTimes = rows + .map((r) => cycleTimeMs(r.decided_at, r.outcome_at)) + .filter((ms): ms is number => ms !== null) + .sort((a, b) => a - b); + + const bucketShare = (bucket: string): number => + decided > 0 ? rows.filter((r) => bucketReasonCode(r.reasoncode) === bucket).length / decided : 0; + + const body: FederatedSignalBundleBody = { + schemaVersion: FEDERATED_BUNDLE_SCHEMA_VERSION, + instanceId: instance, + generatedAt: new Date(now).toISOString(), + windowDays, + decided, + mergePrecision: eligible && metrics ? metrics.mergePrecision : null, + closePrecision: eligible && metrics ? metrics.closePrecision : null, + fpRate: eligible && metrics ? metrics.fpRate : null, + fnRate: eligible && metrics ? metrics.fnRate : null, + reversalRate: metrics ? metrics.reversalRate : 0, + cycleP50Ms: eligible ? percentile(cycleTimes, 50) : null, + cycleP95Ms: eligible ? percentile(cycleTimes, 95) : null, + slopRate: bucketShare("slop_advisory"), + copycatRate: bucketShare("duplicate_risk"), + }; + + return { ...body, signature: signFederatedBundle(body, secret) }; + } catch (error) { + console.error( + JSON.stringify({ level: "error", event: "federated_bundle_failed", message: String(error).slice(0, 200) }), + ); + return null; + } +} diff --git a/src/selfhost/orb-collector.ts b/src/selfhost/orb-collector.ts index 3798dacda8..285babab7a 100644 --- a/src/selfhost/orb-collector.ts +++ b/src/selfhost/orb-collector.ts @@ -55,8 +55,11 @@ interface OrbExportPayload { /** Stable instance identifier (hash of the Orb/App ID — no PII). A brokered instance holds no App id, so its * dedicated anonymization secret becomes the stable identity so ORB_ENROLLMENT_SECRET is never reused as - * a correlatable telemetry identifier. */ -function instanceId(anonSecret: string): string { + * a correlatable telemetry identifier. + * + * Exported so the federated bundle export (#1970, src/orb/federated-bundle.ts) reuses this exact opaque handle + * rather than minting a second instance identity with different de-anonymization properties. */ +export function instanceId(anonSecret: string): string { const seed = process.env.ORB_APP_ID ?? process.env.GITHUB_APP_ID ?? `anon:${anonSecret}`; return createHash("sha256").update(seed).digest("hex").slice(0, 16); } @@ -141,8 +144,11 @@ const FLEET_QUERY = ` ORDER BY event_at ASC, target_id ASC LIMIT ?`; -/** ms between the gate decision and the resolution; null if implausible (NaN or negative). */ -function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null { +/** ms between the gate decision and the resolution; null if implausible (NaN or negative). + * + * Exported for the federated bundle export (#1970, src/orb/federated-bundle.ts) so its cycle-time percentiles + * measure the same interval, with the same implausible-value rejection, as the orb pipeline's. */ +export function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null { const ms = new Date(outcomeAt).getTime() - new Date(decidedAt).getTime(); return Number.isFinite(ms) && ms >= 0 ? ms : null; } diff --git a/src/signals/focus-manifest-loader.ts b/src/signals/focus-manifest-loader.ts index 75de0b7a6f..201f1a98f7 100644 --- a/src/signals/focus-manifest-loader.ts +++ b/src/signals/focus-manifest-loader.ts @@ -1,7 +1,7 @@ import { listSignalSnapshots, persistSignalSnapshot } from "../db/repositories"; import type { JsonValue } from "../types"; import { nowIso } from "../utils/json"; -import { contentLaneConfigToJson, experimentalConfigToJson, featuresConfigToJson, gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, repoDocGenerationConfigToJson, reviewConfigToJson, reviewRecapConfigToJson, maintainerRecapConfigToJson, opsConfigToJson, publicStatsConfigToJson, draftFlowConfigToJson, upstreamDriftIssuesConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource, type RepoReviewContext } from "./focus-manifest"; +import { contentLaneConfigToJson, experimentalConfigToJson, featuresConfigToJson, gateConfigToJson, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifest, parseFocusManifestContent, repoDocGenerationConfigToJson, reviewConfigToJson, reviewRecapConfigToJson, maintainerRecapConfigToJson, opsConfigToJson, publicStatsConfigToJson, draftFlowConfigToJson, upstreamDriftIssuesConfigToJson, federatedIntelligenceConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestSource, type RepoReviewContext } from "./focus-manifest"; import { LOOPOVER_REPO_FOCUS_MANIFEST_YAML, resolveLoopOverSelfRepoFullName } from "../config/loopover-repo-focus-manifest"; import type { LocalManifestLoadResult } from "../selfhost/private-config"; @@ -331,6 +331,7 @@ function manifestToJson(manifest: FocusManifest): Record { publicStats: publicStatsConfigToJson(manifest.publicStats), draftFlow: draftFlowConfigToJson(manifest.draftFlow), upstreamDriftIssues: upstreamDriftIssuesConfigToJson(manifest.upstreamDriftIssues), + federatedIntelligence: federatedIntelligenceConfigToJson(manifest.federatedIntelligence), }; } diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 6fc6ab05c6..69127423a5 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -39,6 +39,7 @@ export { publicStatsConfigToJson, draftFlowConfigToJson, upstreamDriftIssuesConfigToJson, + federatedIntelligenceConfigToJson, settingsOverrideToJson, type AiReviewCadence, type AutoReviewConfig, @@ -69,6 +70,7 @@ export { type FocusManifestPublicStatsConfig, type FocusManifestDraftFlowConfig, type FocusManifestUpstreamDriftIssuesConfig, + type FocusManifestFederatedIntelligenceConfig, type FocusManifestSettings, type FocusManifestSource, type LinkedIssueSatisfactionMode, diff --git a/test/unit/federated-bundle.test.ts b/test/unit/federated-bundle.test.ts new file mode 100644 index 0000000000..5ccb4521e4 --- /dev/null +++ b/test/unit/federated-bundle.test.ts @@ -0,0 +1,329 @@ +import { DatabaseSync } from "node:sqlite"; +import { createHmac } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import { createD1Adapter, nodeSqliteDriver } from "../../src/selfhost/d1-adapter"; +import { getOrCreateAnonSecret, instanceId } from "../../src/selfhost/orb-collector"; +import { + FEDERATED_BUNDLE_SCHEMA_VERSION, + buildFederatedBundle, + canonicalizeFederatedBundleBody, + isFederatedIntelligenceEnabled, + signFederatedBundle, + type FederatedSignalBundle, +} from "../../src/orb/federated-bundle"; +import type { FocusManifest } from "../../src/signals/focus-manifest"; + +/** In-memory DB with the review_audit + system_flags tables the bundle builder reads. Mirrors + * selfhost-orb-collector.test.ts's makeDb (same source tables, minus the export cursor this path never uses). */ +function makeDb(): D1Database { + const driver = nodeSqliteDriver(new DatabaseSync(":memory:") as never); + driver.exec(` + CREATE TABLE review_audit ( + id TEXT PRIMARY KEY NOT NULL, project TEXT NOT NULL, target_id TEXT NOT NULL, + event_type TEXT NOT NULL DEFAULT 'gate_decision', decision TEXT, + source TEXT NOT NULL DEFAULT 'gittensory-native', head_sha TEXT, summary TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ); + CREATE TABLE system_flags ( + key TEXT PRIMARY KEY, value TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ); + `); + return createD1Adapter(driver); +} + +let seq = 0; +async function audit( + db: D1Database, + pr: number, + eventType: string, + decision: string | null, + at: string, + summary: string | null = null, +): Promise { + await db + .prepare( + `INSERT INTO review_audit (id, project, target_id, event_type, decision, source, summary, created_at) VALUES (?, ?, ?, ?, ?, 'gittensory-native', ?, ?)`, + ) + .bind(`r${seq++}`, "owner/repo", `owner/repo#${pr}`, eventType, decision, summary, at) + .run(); +} + +/** One fully-resolved PR: a gate decision plus its realized human outcome (optionally reversed). */ +async function resolved( + db: D1Database, + pr: number, + o: { + verdict?: string; + outcome?: string; + decidedAt?: string; + outcomeAt?: string; + summary?: string | null; + reversal?: "reversal_reverted" | "reversal_reopened"; + } = {}, +): Promise { + await audit(db, pr, "gate_decision", o.verdict ?? "merge", o.decidedAt ?? "2026-07-10T10:00:00Z", o.summary ?? null); + await audit(db, pr, "pr_outcome", o.outcome ?? "merged", o.outcomeAt ?? "2026-07-10T12:00:00Z"); + if (o.reversal) await audit(db, pr, o.reversal, null, "2026-07-10T13:00:00Z"); +} + +/** A manifest carrying only the block the builder reads. */ +function manifest(enabled: boolean | undefined): Pick { + return { federatedIntelligence: { present: enabled !== undefined, enabled: enabled ?? false } }; +} + +/** A db that fails the test if it is touched at all — proves the opted-out path reads nothing. */ +function untouchableDb(): D1Database { + return new Proxy({} as D1Database, { + get() { + throw new Error("opted-out build must not touch the database"); + }, + }); +} + +const NOW = Date.parse("2026-07-16T00:00:00Z"); + +describe("isFederatedIntelligenceEnabled()", () => { + it("is false for a null/undefined manifest, an absent block, and an explicit false", () => { + expect(isFederatedIntelligenceEnabled(null)).toBe(false); + expect(isFederatedIntelligenceEnabled(undefined)).toBe(false); + expect(isFederatedIntelligenceEnabled({} as Pick)).toBe(false); + expect(isFederatedIntelligenceEnabled(manifest(false))).toBe(false); + }); + + it("is true only for an explicit enabled: true", () => { + expect(isFederatedIntelligenceEnabled(manifest(true))).toBe(true); + }); +}); + +describe("buildFederatedBundle() — opted out (the default posture)", () => { + it("returns null and touches neither the database nor the network when the block is absent", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + expect(await buildFederatedBundle(manifest(undefined), untouchableDb())).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it("returns null for an explicit enabled: false", async () => { + expect(await buildFederatedBundle(manifest(false), untouchableDb())).toBeNull(); + }); + + it("returns null for a null manifest (no manifest loaded at all)", async () => { + expect(await buildFederatedBundle(null, untouchableDb())).toBeNull(); + }); +}); + +describe("buildFederatedBundle() — opted in", () => { + it("builds a fully-populated, correctly-signed bundle and still makes no network call", async () => { + const db = makeDb(); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + // 5 decided PRs = MIN_DECIDED: 3 confirmed merges, 1 reverted merge (a false positive), 1 confirmed close. + await resolved(db, 1); + await resolved(db, 2); + await resolved(db, 3, { summary: "ai_slop_advisory" }); + await resolved(db, 4, { reversal: "reversal_reverted" }); + await resolved(db, 5, { verdict: "close", outcome: "closed", summary: "duplicate_pr_risk" }); + + const bundle = await buildFederatedBundle(manifest(true), db, { now: NOW }); + expect(bundle).not.toBeNull(); + const b = bundle as FederatedSignalBundle; + + expect(b.schemaVersion).toBe(FEDERATED_BUNDLE_SCHEMA_VERSION); + expect(b.windowDays).toBe(90); + expect(b.generatedAt).toBe("2026-07-16T00:00:00.000Z"); + expect(b.decided).toBe(5); + // 4 merge verdicts, 3 confirmed (the reverted one is a false positive) → 0.75. + expect(b.mergePrecision).toBeCloseTo(0.75); + expect(b.fpRate).toBeCloseTo(0.25); + // 1 close verdict, confirmed closed → 1.0. + expect(b.closePrecision).toBe(1); + expect(b.fnRate).toBe(0); + expect(b.reversalRate).toBeCloseTo(0.2); // 1 of 5 reversed + expect(b.slopRate).toBeCloseTo(0.2); // 1 of 5 bucketed slop_advisory + expect(b.copycatRate).toBeCloseTo(0.2); // 1 of 5 bucketed duplicate_risk + expect(b.cycleP50Ms).toBe(7_200_000); // 2h decision → outcome + expect(b.cycleP95Ms).toBe(7_200_000); + + // The opaque handle is the orb pipeline's, not a second identity. + expect(b.instanceId).toBe(instanceId(await getOrCreateAnonSecret(db))); + + // Independently recompute the HMAC over the canonical body — the signature must verify. + const { signature, ...body } = b; + const secret = await getOrCreateAnonSecret(db); + expect(signature).toBe(createHmac("sha256", secret).update(canonicalizeFederatedBundleBody(body)).digest("hex")); + expect(signature).toMatch(/^[0-9a-f]{64}$/); + + // The export path itself never talks to anyone — transport is #6479. + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it("carries only the enumerated anonymized fields — adding one must fail this test on purpose", async () => { + const db = makeDb(); + await resolved(db, 1); + const b = (await buildFederatedBundle(manifest(true), db, { now: NOW })) as FederatedSignalBundle; + expect(Object.keys(b).sort()).toEqual( + [ + "closePrecision", + "copycatRate", + "cycleP50Ms", + "cycleP95Ms", + "decided", + "fnRate", + "fpRate", + "generatedAt", + "instanceId", + "mergePrecision", + "reversalRate", + "schemaVersion", + "signature", + "slopRate", + "windowDays", + ].sort(), + ); + }); + + it("leaks no identifier: no repo name, PR id, login, or raw gate reason reaches the bundle", async () => { + const db = makeDb(); + await resolved(db, 1, { summary: "duplicate_pr_risk against owner/repo#99 by octocat" }); + const b = (await buildFederatedBundle(manifest(true), db, { now: NOW })) as FederatedSignalBundle; + const text = JSON.stringify(b); + expect(text).not.toMatch(/owner\/repo/); + expect(text).not.toMatch(/octocat/); + expect(text).not.toMatch(/duplicate_pr_risk/); // the raw reason is bucketed, never carried + expect(text).not.toMatch(/target_id|project|repo_hash|pr_hash/); + }); + + it("counts a reopened close as a reversal and a false negative", async () => { + const db = makeDb(); + await resolved(db, 1, { verdict: "close", outcome: "closed", reversal: "reversal_reopened" }); + const b = (await buildFederatedBundle(manifest(true), db, { now: NOW })) as FederatedSignalBundle; + expect(b.reversalRate).toBe(1); + expect(b.decided).toBe(1); + }); +}); + +describe("buildFederatedBundle() — the MIN_DECIDED eligibility bar", () => { + it("publishes null precision/cycle below MIN_DECIDED, while still reporting the defined rates", async () => { + const db = makeDb(); + await resolved(db, 1); + await resolved(db, 2, { reversal: "reversal_reverted", summary: "ai_slop_advisory" }); + const b = (await buildFederatedBundle(manifest(true), db, { now: NOW })) as FederatedSignalBundle; + + expect(b.decided).toBe(2); // below MIN_DECIDED (5) + expect(b.mergePrecision).toBeNull(); + expect(b.closePrecision).toBeNull(); + expect(b.fpRate).toBeNull(); + expect(b.fnRate).toBeNull(); + expect(b.cycleP50Ms).toBeNull(); + expect(b.cycleP95Ms).toBeNull(); + // Rates are still defined — they do not claim a precision the fleet would refuse to count. + expect(b.reversalRate).toBeCloseTo(0.5); + expect(b.slopRate).toBeCloseTo(0.5); + expect(b.copycatRate).toBe(0); + }); + + it("an opted-in instance with no resolved PRs yields a signed, all-null-metric bundle rather than nothing", async () => { + const db = makeDb(); + const b = (await buildFederatedBundle(manifest(true), db, { now: NOW })) as FederatedSignalBundle; + expect(b).not.toBeNull(); + expect(b.decided).toBe(0); + expect(b.mergePrecision).toBeNull(); + expect(b.reversalRate).toBe(0); // not NaN + expect(b.slopRate).toBe(0); + expect(b.copycatRate).toBe(0); + expect(b.cycleP50Ms).toBeNull(); + expect(b.signature).toMatch(/^[0-9a-f]{64}$/); + }); + + it("excludes PRs resolved outside the window", async () => { + const db = makeDb(); + await resolved(db, 1, { decidedAt: "2020-01-01T10:00:00Z", outcomeAt: "2020-01-01T12:00:00Z" }); + const b = (await buildFederatedBundle(manifest(true), db, { now: NOW })) as FederatedSignalBundle; + expect(b.decided).toBe(0); + }); +}); + +describe("buildFederatedBundle() — window resolution (mirrors computeFleetAnalytics)", () => { + it("defaults to 90 days, honors a valid window, clamps at 365, and rejects a non-positive one", async () => { + const db = makeDb(); + // exactOptionalPropertyTypes: an omitted window must be genuinely absent, not `windowDays: undefined`. + const w = async (windowDays?: number): Promise => + ( + (await buildFederatedBundle( + manifest(true), + db, + windowDays === undefined ? { now: NOW } : { windowDays, now: NOW }, + )) as FederatedSignalBundle + ).windowDays; + expect(await w(undefined)).toBe(90); + expect(await w(30)).toBe(30); + expect(await w(9999)).toBe(365); + expect(await w(0)).toBe(90); + expect(await w(-5)).toBe(90); + }); + + it("defaults generatedAt to the current clock when no now is injected", async () => { + const db = makeDb(); + const before = Date.now(); + const b = (await buildFederatedBundle(manifest(true), db)) as FederatedSignalBundle; + expect(Date.parse(b.generatedAt)).toBeGreaterThanOrEqual(before); + }); +}); + +describe("buildFederatedBundle() — fail-safe", () => { + it("returns null instead of throwing when the database read fails, so the gate is never affected", async () => { + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const brokenDb = { + prepare() { + throw new Error("d1 exploded"); + }, + } as unknown as D1Database; + await expect(buildFederatedBundle(manifest(true), brokenDb)).resolves.toBeNull(); + expect(err).toHaveBeenCalled(); + err.mockRestore(); + }); + + it("treats a driver returning no result rows as an empty window rather than crashing", async () => { + const db = makeDb(); + const secret = await getOrCreateAnonSecret(db); + const noRowsDb = { + prepare: (sql: string) => ({ + bind: (...args: unknown[]) => ({ + first: async () => ({ value: secret }), + all: async () => ({ results: undefined }), + run: async () => ({}), + }), + first: async () => ({ value: secret }), + all: async () => ({ results: undefined }), + }), + } as unknown as D1Database; + const b = (await buildFederatedBundle(manifest(true), noRowsDb, { now: NOW })) as FederatedSignalBundle; + expect(b.decided).toBe(0); + }); +}); + +describe("canonicalizeFederatedBundleBody() / signFederatedBundle()", () => { + it("is insensitive to key insertion order, so a receiver can recompute the HMAC byte-for-byte", async () => { + const db = makeDb(); + await resolved(db, 1); + const { signature: _sig, ...body } = (await buildFederatedBundle(manifest(true), db, { + now: NOW, + })) as FederatedSignalBundle; + + // Same values, deliberately rebuilt in reverse key order. + const shuffled = Object.fromEntries(Object.entries(body).reverse()) as typeof body; + expect(canonicalizeFederatedBundleBody(shuffled)).toBe(canonicalizeFederatedBundleBody(body)); + expect(signFederatedBundle(shuffled, "k")).toBe(signFederatedBundle(body, "k")); + }); + + it("changes the signature when any signed field changes (tamper-evidence)", async () => { + const db = makeDb(); + await resolved(db, 1); + const { signature: _sig, ...body } = (await buildFederatedBundle(manifest(true), db, { + now: NOW, + })) as FederatedSignalBundle; + expect(signFederatedBundle({ ...body, decided: body.decided + 1 }, "k")).not.toBe(signFederatedBundle(body, "k")); + expect(signFederatedBundle(body, "other-key")).not.toBe(signFederatedBundle(body, "k")); + }); +}); diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 852a5fe5e2..81648f6123 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -45,6 +45,7 @@ import { publicStatsConfigToJson, draftFlowConfigToJson, upstreamDriftIssuesConfigToJson, + federatedIntelligenceConfigToJson, settingsOverrideToJson, type FocusManifest, type FocusManifestContentLaneConfig, @@ -923,6 +924,7 @@ describe("compileFocusManifestPolicy", () => { publicStats: { present: false, enabled: false }, draftFlow: { present: false, enabled: false }, upstreamDriftIssues: { present: false, enabled: false }, + federatedIntelligence: { present: false, enabled: false }, warnings: [], }); expect(policy.publicSafe.entryGuidance).toContain("Keep PRs focused."); @@ -2118,6 +2120,54 @@ describe("parseFocusManifest gate config", () => { }); }); + describe("federatedIntelligence: (#1970, opt-in federated fleet intelligence export config-as-code toggle)", () => { + it("defaults to fully disabled/absent when the key is omitted, and does not make the manifest present on its own", () => { + const m = parseFocusManifest({}); + expect(m.federatedIntelligence).toEqual({ present: false, enabled: false }); + expect(m.present).toBe(false); + }); + + it("treats an explicit null the same as an omitted key", () => { + expect(parseFocusManifest({ federatedIntelligence: null }).federatedIntelligence).toEqual({ present: false, enabled: false }); + }); + + it("warns and falls back to the default when the value is a non-mapping type (string or array)", () => { + const asString = parseFocusManifest({ federatedIntelligence: "nope" as never }); + expect(asString.federatedIntelligence.present).toBe(false); + expect(asString.warnings.some((w) => /"federatedIntelligence" must be a mapping/.test(w))).toBe(true); + const asArray = parseFocusManifest({ federatedIntelligence: ["nope"] as never }); + expect(asArray.federatedIntelligence.present).toBe(false); + expect(asArray.warnings.some((w) => /"federatedIntelligence" must be a mapping/.test(w))).toBe(true); + }); + + it("parses enabled: true, making the manifest present", () => { + const m = parseFocusManifest({ federatedIntelligence: { enabled: true } }); + expect(m.federatedIntelligence).toEqual({ present: true, enabled: true }); + expect(m.present).toBe(true); + }); + + it("parses enabled: false explicitly, still making the manifest present", () => { + const m = parseFocusManifest({ federatedIntelligence: { enabled: false } }); + expect(m.federatedIntelligence).toEqual({ present: true, enabled: false }); + expect(m.present).toBe(true); + }); + + it("warns and defaults to false when enabled is a non-boolean value", () => { + const m = parseFocusManifest({ federatedIntelligence: { enabled: "yes" as unknown as boolean } }); + expect(m.federatedIntelligence.enabled).toBe(false); + expect(m.warnings.some((w) => /federatedIntelligence\.enabled/.test(w))).toBe(true); + }); + + it("round-trips through federatedIntelligenceConfigToJson -> parseFocusManifest unchanged", () => { + const m = parseFocusManifest({ federatedIntelligence: { enabled: true } }); + expect(parseFocusManifest({ federatedIntelligence: federatedIntelligenceConfigToJson(m.federatedIntelligence) }).federatedIntelligence).toEqual(m.federatedIntelligence); + }); + + it("federatedIntelligenceConfigToJson returns null for an absent config", () => { + expect(federatedIntelligenceConfigToJson(parseFocusManifest(null).federatedIntelligence)).toBeNull(); + }); + }); + it("parses aiReviewAllAuthors from the settings: block (generic override)", () => { const parsed = parseFocusManifest({ settings: { aiReviewAllAuthors: true , closeOwnerAuthors: false} }); expect(parsed.settings.aiReviewAllAuthors).toBe(true);