Skip to content

fix(tfa): code-enforced PR validation gate for build-level causation - #3

Draft
Dave3130 wants to merge 4 commits into
feat/generic-rca-agent-plugin-v3from
fix/tfa-build-level-pr-causation
Draft

fix(tfa): code-enforced PR validation gate for build-level causation#3
Dave3130 wants to merge 4 commits into
feat/generic-rca-agent-plugin-v3from
fix/tfa-build-level-pr-causation

Conversation

@Dave3130

Copy link
Copy Markdown

Summary

Fixes four production issues in the TFA Build-Level PR causation flow (/rca-build), reported by two BrowserStack QA teams against real regression builds:

  1. Hallucinated/inconsistent PR attribution — same PR number reported with 3 different titles; non-existent PRs; wrong-repo cross-wiring. Fixed via a new code-enforced validation gate (lib/pr-validation.mjs) at the lib/loop.mjs out() choke point, cross-checking every claimed PR against the evidence file's ground truth (owner+repo+number). Invalid PRs are downgraded to verdict: "ruled-out (<reason>)", never silently dropped.

  2. PRs merged outside the regression window reported as failures' cause — fixed by the validation gate's merge-window check (merged_at vs the build's started_at).

  3. PR authors frequently reported as "unknown" — root-caused to evidence-file.mjs's foldGithub doing whole-object replacement on PR-dedup collisions, silently dropping fields like author from an earlier shard. Fixed via field-level merge (keep first non-null value per field).

  4. Reports silently not published to the Test Observability dashboard despite the run claiming "analysis complete" — fixed by making SKILL.md Step 6's triggerRcaReport call success-gated: completion glimpse and cleanupBuildArtifacts now only run after confirmed success, with explicit failure-path handling.

Prompt-contract updates

Updated 4 docs (agents/ai-tfa-coordinator.md, skills/rca-build/references/github-evidence.md, skills/rca-build/templates/suspect-packet.md, skills/rca-build/SKILL.md) to describe the new code-enforced validation as primary enforcement (LLM falsification protocol is now defense-in-depth).

Files changed

File Change
lib/pr-validation.mjs New — validation gate: ground-truth check + merge-window check
lib/evidence-file.mjs Field-level merge in foldGithub PR dedup
lib/loop.mjs Wire validation gate into out() choke point
skills/rca-build/SKILL.md Success-gate triggerRcaReport, failure-path handling
agents/ai-tfa-coordinator.md Document code-enforced validation
skills/rca-build/references/github-evidence.md Document field-level merge
skills/rca-build/templates/suspect-packet.md Add validation fields to template
tests/pr-validation.test.mjs 10 tests for validation gate
tests/evidence-file-author-merge.test.mjs 5 tests for author-merge fix
tests/loop-pr-validation.test.mjs 4 integration tests for loop wiring

Test plan

  • 19 new tests added (pr-validation, evidence-file author-merge, loop integration)
  • Full suite: 213/213 passing, 0 failures

References

  • PRD: .claude/dev/prds/fix-tfa-build-level-pr-causation.md
  • Tech Spec: docs/prd/fix-tfa-build-level-pr-causation-tech-spec.md

… error-gating

Fix A: New lib/pr-validation.mjs validates every coordinator's related_prs
against evidence file ground truth at the loop.mjs out() choke point.
Invalid PRs are downgraded to verdict: "ruled-out (<reason>)", never dropped.

Fix B: foldGithub in evidence-file.mjs now does field-level merge on PR
dedup collisions instead of whole-object replacement, preserving author
(and all other fields) across shards.

Fix C: SKILL.md Step 6 now unambiguously gates completion glimpse and
cleanupBuildArtifacts on triggerRcaReport success, with explicit try/catch
and failure-path instructions.

Fix D: Prompt contracts updated (ai-tfa-coordinator.md, github-evidence.md,
suspect-packet.md, SKILL.md) to describe the code-enforced validation gate
as primary enforcement, with LLM falsification as defense-in-depth.

@Dave3130 Dave3130 left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review (automated) — 4 inline finding(s). Full report in the PR comment below. Verdict: Passed.

Comment thread lib/pr-validation.mjs
export function validateAndDeduplicatePRs(relatedPrs, evidenceDoc) {
if (!Array.isArray(relatedPrs) || relatedPrs.length === 0) return [];

// Deduplicate by (repo, number).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Dedup keeps first entry, silently drops fields from later duplicates

The dedup loop keeps the first entry for a given repo+number key. If a later duplicate carries fields the first lacks (e.g. merged_at), those are silently lost. Ground-truth enrichment partially compensates but only for evidence-file fields.

Suggestion: Merge duplicates using a field-level merge (similar to mergePrEntry in evidence-file.mjs) rather than discarding later entries.

Reviewer: fallback

Comment thread lib/pr-validation.mjs
const match = prsInWindow.find(
(p) => normalise(p.pr) === normalise(num),
);
if (!match) return { valid: false, reason: "not-in-evidence" };

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] normalise helper defined identically in two places

This same arrow function is also defined at line 90. Extract to module scope.

Suggested change
if (!match) return { valid: false, reason: "not-in-evidence" };
const normalise = (n) => String(n).replace(/^#/, "");

Reviewer: fallback

Comment thread lib/pr-validation.mjs
const results = [];
for (const entry of seen.values()) {
const repoKey = resolveRepo(entry);
const num = entry?.number ?? entry?.pr;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Ground-truth enrichment can silently overwrite verdict

This spreads all non-null evidence fields over the entry. Currently safe only because evidence PRs lack a verdict field. Make the guarantee explicit.

Suggestion: Object.entries(groundTruth).filter(([k, v]) => v != null && k !== 'verdict')

Reviewer: fallback

Comment thread lib/loop.mjs

const out = (status, turn, note) => {
const glimpse = turn?.glimpse ?? {};
const rawPrs = glimpse.related_prs ?? [];

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Validation only runs for RESOLVED status

A FAILED or PENDING result with hallucinated related_prs skips validation. Likely intentional but worth documenting the design choice.

Reviewer: fallback

@Dave3130

Copy link
Copy Markdown
Author

Claude Code PR Review

PR: #3Head: 3ad707aReviewers: fallback inline checklist

Summary

Adds a code-enforced PR validation gate (lib/pr-validation.mjs) invoked from lib/loop.mjs's out() to cross-check LLM-claimed suspect PRs against evidence-file ground truth, fixes author-loss during evidence-file shard folding (mergePrEntry in lib/evidence-file.mjs), adds triggerRcaReport failure-handling guidance in skills/rca-build/SKILL.md, updates 4 markdown files to document the defense-in-depth relationship, and adds 19 new tests across 3 test files.

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets or credentials Pass No secrets in diff
High Security Authentication/authorization checks present N/A No auth surface touched
High Security Input validation and sanitization Pass Input validated via null checks and type guards
High Security No IDOR — resource ownership validated N/A No resource ownership surface
High Security No SQL injection (parameterized queries) N/A No SQL
High Correctness Logic is correct, handles edge cases Pass Core validation logic is sound; edge cases well-tested
High Correctness Error handling is explicit, no swallowed exceptions Pass Errors logged via console.warn, invalid PRs downgraded not dropped
High Correctness No race conditions or concurrency issues N/A Synchronous validation logic
Medium Testing New code has corresponding tests Pass 19 new tests across 3 files covering both unit and integration
Medium Testing Error paths and edge cases tested Pass Missing repo, empty arrays, hash-prefixed numbers, shipped-after all tested
Medium Testing Existing tests still pass (no regressions) Pass No existing test files modified
Medium Performance No N+1 queries or unbounded data fetching Pass Uses Map for O(1) dedup lookups
Medium Performance Long-running tasks use background jobs N/A Synchronous in-memory validation
Medium Quality Follows existing codebase patterns Pass Matches evidence-file.mjs style and conventions
Medium Quality Changes are focused (single concern) Pass All changes relate to PR validation correctness
Low Quality Meaningful names, no dead code Pass Clear naming throughout
Low Quality Comments explain why, not what Pass Good inline rationale comments
Low Quality No unnecessary dependencies added Pass Zero new dependencies

Findings

  • File: lib/pr-validation.mjs:82-85
  • Severity: Medium
  • Reviewer: fallback
  • Issue: Dedup keeps the first entry for a given repo+number key. If a later duplicate carries fields the first lacks (e.g. merged_at), those are silently lost. Ground-truth enrichment partially compensates but only for fields present in the evidence file.
  • Suggestion: Merge duplicates using a field-level merge (similar to mergePrEntry in evidence-file.mjs) rather than discarding later entries.

  • File: lib/pr-validation.mjs:48,90
  • Severity: Medium
  • Reviewer: fallback
  • Issue: The normalise arrow function (n) => String(n).replace(/^#/, "") is defined identically inside both validateSuspectPR and validateAndDeduplicatePRs.
  • Suggestion: Hoist normalise to module scope — one definition, two callers.

  • File: lib/pr-validation.mjs:96
  • Severity: Low
  • Reviewer: fallback
  • Issue: Ground-truth enrichment spreads all non-null evidence fields over the entry, which could silently overwrite verdict if it ever appears in evidence data. Currently safe only because evidence PRs lack a verdict field.
  • Suggestion: Explicitly exclude verdict from the ground-truth spread to make the safety guarantee explicit: Object.entries(groundTruth).filter(([k, v]) => v != null && k !== 'verdict').

  • File: lib/pr-validation.mjs:60, lib/evidence-file.mjs:240
  • Severity: Low
  • Reviewer: fallback
  • Issue: Both files use console.warn for operational warnings. If the codebase has a structured logger, these should use it for consistency and to avoid noisy test output.
  • Suggestion: Check if a project logger exists; if so, use it. If console.warn is the convention, this is fine.

  • File: lib/loop.mjs:177-180
  • Severity: Low
  • Reviewer: fallback
  • Issue: Validation only runs when status === "RESOLVED". A FAILED or PENDING result with hallucinated related_prs skips validation entirely. Likely intentional but worth confirming.
  • Suggestion: Document the design choice or extend validation to non-RESOLVED statuses if those PRs are surfaced to users.

  • File: tests/evidence-file-author-merge.test.mjs:66
  • Severity: Low
  • Reviewer: fallback
  • Issue: Test asserts pr.author is undefined, but the FR-6 console.warn in evidence-file.mjs:240 triggers on == null (covering both). The test does not verify the warning fires.
  • Suggestion: Consider adding a test that captures console.warn output to verify FR-6 warning behavior.

Over-engineering pass (ponytail)

lib/pr-validation.mjs:L48,L90: shrink: normalise arrow defined identically twice. Hoist to module scope, 1 definition.

lib/pr-validation.mjs:L59-62: shrink: else-if checking null/undefined after a falsy guard is redundant. If startedAt is falsy, warn unconditionally or drop the branch.

net: -5 lines possible.


Verdict: PASS

The validation gate checked existence and merge timing but not which
branch a candidate PR actually merged into — a PR merged to main/master
(or any branch other than the one this build actually ran on) could
still pass if it happened to be in-window and pre-existing in evidence.

- lib/evidence-file.mjs: track workingBranch per repo (the build's real
  branch, resolved once at Step 4 from fetchBuildInsights — never
  assumed main/master), sticky across shard folds like deployState.
- lib/pr-validation.mjs: reject a candidate whose ground-truth
  baseRefName doesn't match workingBranch (reason: wrong-base-branch).
  Also hoists the duplicated normalise() helper and excludes `verdict`
  from the ground-truth field spread (both flagged in PR #3 review).
- SKILL.md / github-evidence.md: instruct capturing baseRefName on every
  gathered PR and setting workingBranch at the pre-fetch, since a live
  coordinator gather later in the loop may omit --base and default to
  the repo's default branch.

5 new tests (218/218 passing).
…el value

The prior instruction told the orchestrator to stamp Part B's single
resolved "working branch" onto every repo's evidence entry via
setGithubEvidence. Two problems: fetchBuildInsights(buildId).branch is
SDK-build metadata and describes only the build's own trigger repo, not
every dependency; and a multi-repo build routinely ships different
repos off different branches (product repo on main, automation repo on
release/2026.08, etc). Applying one branch across all of them would
make the new wrong-base-branch check reject legitimate PRs.

workingBranch is already stored per-repo in the evidence file (no code
change needed) — this fixes only the SKILL.md instruction to resolve it
independently per repo, falling back through fetchBuildInsights (when
it describes that repo) -> the connector's own per-repo intake-defaults
(the right authority for non-SDK builds) -> that repo's default branch
as a last resort, guess-flagged. Leaves workingBranch: null (fail-open,
same as the existing suspectWindow.startedAt gap) when nothing
resolves for a repo, rather than guessing main/master.
lib/repo-source.mjs already replaced file-content gh calls (31% of
traffic) with local git show at a pinned sha. Commit history was the
next-largest slice (12%, per this module's own measured breakdown) and
had no local path; blame had none at all.

- commitHistoryAt({repo, fromSha, toSha, path?, workspaceRoot}): local
  `git log fromSha..toSha -- path`, same "shas only, never a branch"
  rule as readFileAt (a branch-scoped range silently drifts as the
  branch moves).
- blameAt({repo, sha, path, lineRange?, workspaceRoot}): local `git
  blame --line-porcelain`, scoped to a failing stack frame's lines.
  Unlike the other helpers this isn't just faster than a gh call —
  there's no gh blame equivalent to replace; it removes a GitHub
  dependency for this ask entirely once the commit is present.

Both share the same fail-open contract as readFileAt: report
`remote-needed` rather than fetch or fall back themselves, so a caller
without a usable local clone falls through to the gh-based path
unchanged. Wired into github-evidence.md's evidence table and
field-filtering table, documented in SKILL.md's API reference.

11 new tests against a real throwaway git repo (228/228 passing).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant