Skip to content

fix: restore evidence integrity in V2 pipeline - #2

Merged
austinintelligence merged 1 commit into
mainfrom
codex/perspectica-evidence-integrity-v3
Aug 3, 2026
Merged

austinintelligence merged 1 commit into
mainfrom
codex/perspectica-evidence-integrity-v3

Conversation

@austinintelligence

@austinintelligence austinintelligence commented Aug 3, 2026 •

Copy link
Copy Markdown
Owner

What changed

  • Split provider discovery from reader-facing evidence: Exa and native ChatGPT now emit candidates only.
  • Added bounded candidate-to-claim adjudication with exact source IDs, claim boundaries, relationships, context signals, and centralized validation before ledger acceptance.
  • Restored grounding checks for article compass/bias signals and restored ArticleIndex-based Works Cited filtering.
  • Implemented bounded ledger-informed political context while keeping article-owned evidence as the 60% anchor.
  • Made event journaling durable-first with idempotent same-sequence retries, gap rejection, paginated replay, and contiguous side-panel delivery.
  • Fixed targeted retry to rerun only failed provider lanes and to distinguish completed-empty from failed.
  • Wired section-level ReportStore subscriptions, transient ChatGPT login polling recovery, and awaited preference persistence with visible save/error state.

Root causes addressed

The merged V2 pipeline promoted provider URLs into assertions based on mission purpose and result position. This corrective PR keeps discovery records out of the ledger until a bounded adjudicator maps a known candidate to an exact claim and relationship; deterministic validation then checks provenance, claim anchors, excerpt mechanics, mission boundaries, context identity, and score direction.

The event path previously advanced the job cursor before the IndexedDB journal write. It now journals first and repairs the cursor on same-sequence retry. Replay requires contiguous sequences and paginates beyond the old 256-event ceiling.

Validation

  • pnpm verify:release
  • pnpm bench:v2
  • pnpm audit --prod --audit-level=high
  • git diff --check

Result: 107 tests across 25 files, typecheck/build/package checks passing, 15 build files, 1,142,549 unpacked build bytes, 1,037,000 JavaScript bytes, 25,146 CSS bytes, and no known production audit vulnerabilities.

Limitation

No live authenticated Exa or ChatGPT analysis was performed in this environment. The benchmark is structural/package evidence, not proof of live provider quality, latency, token use, or reader-facing correctness. Those authenticated runs remain the final release gate.

Summary by CodeRabbit

  • New Features

    • Improved evidence analysis by validating source candidates against article claims before using them in reports.
    • Added political-context signals to compass results when reliable supporting evidence is available.
    • Report sections now load and update independently.
    • Research progress displays the number of discovered candidates.
  • Bug Fixes

    • Improved recovery from interrupted or out-of-order analysis updates.
    • Settings changes now show saving and error status, with failed updates safely rolled back.
    • ChatGPT sign-in polling now retries temporary failures and handles expired authorization correctly.

Copilot AI review requested due to automatic review settings August 3, 2026 05:43
@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change replaces pre-adjudicated evidence cards with provider candidates and centralized validation, adds sequence-safe event persistence and replay, and updates side-panel rendering, settings persistence, authentication polling, and political-context projection.

Changes

Evidence pipeline

Layer / File(s) Summary
Evidence contracts and validation
packages/contracts/src/evidence.ts, packages/intelligence/src/evidence/*, packages/intelligence/src/planning/lens.ts
Defines candidate and adjudication contracts. Validates claims, missions, excerpts, source types, context, and article grounding.
Provider candidate retrieval
apps/extension/src/providers/*, packages/intelligence/src/retrieval/*
Providers emit candidate batches with cache validation, failure states, cancellation, and streamed priority execution.
Ledger and analysis orchestration
packages/intelligence/src/evidence/source-ledger.ts, packages/intelligence/src/pipeline.ts, apps/extension/entrypoints/offscreen/main.ts
Retrieval collects candidates before adjudication. Ledger state tracks candidates, failed missions, and progress counts. Retries use the same flow.
Context and report projection
packages/intelligence/src/compass/calculate.ts, packages/intelligence/src/synthesis/perspective.ts, packages/intelligence/src/report/projector.ts
Compass results can include weighted political context. Source projection uses canonical links from the article index.

Event delivery

Layer / File(s) Summary
Atomic event commit and persistence
apps/extension/src/storage/job-store.ts, apps/extension/src/storage/job-journal.ts, apps/extension/src/runtime/background-controller.ts
Events are persisted before job cursors advance. Duplicate retries are idempotent, and sequence gaps are rejected.
Sequence-aware delivery and replay
apps/extension/entrypoints/sidepanel/api.ts, apps/extension/entrypoints/sidepanel/api.test.ts
The stream buffers gaps, serializes paginated replay, validates contiguous sequences, and applies events in order.

Side-panel reliability

Layer / File(s) Summary
Connected report sections
apps/extension/entrypoints/sidepanel/App.tsx
Report sections subscribe independently to ReportStore snapshots and render their own state.
Asynchronous settings persistence
apps/extension/entrypoints/sidepanel/SettingsScreen.tsx, apps/extension/entrypoints/sidepanel/App.tsx
Preference saves support promises, expose save status, and roll back optimistic state on failure.
Authentication and progress contracts
apps/extension/entrypoints/sidepanel/ChatGptConnection.tsx, apps/extension/entrypoints/sidepanel/report-state.ts
Authentication distinguishes expired and transient polling failures. Research progress includes candidate counts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • drperky20/perspectica#1: The change extends the earlier V2 intelligence pipeline, evidence ledger, report state, and event replay implementation.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.26% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: restoring evidence integrity in the V2 pipeline.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/perspectica-evidence-integrity-v3

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@austinintelligence
austinintelligence merged commit 9a5bcd5 into main Aug 3, 2026
3 of 4 checks passed
@austinintelligence
austinintelligence deleted the codex/perspectica-evidence-integrity-v3 branch August 3, 2026 05:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Not ready to approve

Several confirmed logic issues remain (context subject matching rejects short identities, compass confidence labeling inconsistency, and scheduler cancellation behavior not actually implemented) that should be addressed before merging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

This PR restores evidence integrity and durability in the V2 analysis pipeline by preventing provider discovery results from becoming reader-facing assertions until a bounded adjudication step maps exact candidate IDs to explicit claims/relationships, followed by centralized validation and ledger acceptance. It also strengthens event journaling/replay semantics and refines UI/state handling around progress, retries, and preference persistence.

Changes:

  • Reworked retrieval → ledger flow to store provider results as EvidenceCandidates and introduce adjudication + centralized validation before creating ledger assertions/sources.
  • Restored grounding/validation for article compass/bias signals and tightened Works Cited projection using ArticleIndex link classifications.
  • Made event journaling “durable-first” with idempotent retries, gap rejection, paginated replay, and more section-isolated UI subscriptions.
File summaries
File Description
README.md Updates V2 narrative to reflect candidate-only providers and adjudication/validation boundary.
packages/intelligence/src/synthesis/perspective.ts Adds political context synthesis from validated ledger assertions and applies context-aware compass projection.
packages/intelligence/src/retrieval/types.ts Switches retrieval surface export from legacy cards to EvidenceCandidate.
packages/intelligence/src/retrieval/scheduler.ts Introduces streaming priority scheduler API used by providers.
packages/intelligence/src/retrieval/coordinator.ts Adds candidate-count progress and adjusts retry logic to target failed missions.
packages/intelligence/src/report/projector.ts Projects Works Cited from ArticleIndex with canonical URL normalization + filtering/deduping.
packages/intelligence/src/report/projector.test.ts Adds tests for Works Cited link filtering behavior.
packages/intelligence/src/planning/lens.ts Grounds article compass/bias signals against actual index paragraph/sentence anchors.
packages/intelligence/src/planning/lens.test.ts Adds tests for article signal grounding and excerpt substring requirements.
packages/intelligence/src/pipeline.ts Adds adjudication phase, accumulates candidates during retrieval, and updates retry/partial-status semantics.
packages/intelligence/src/pipeline.test.ts Updates pipeline tests for candidates/adjudication boundary and targeted retry behavior.
packages/intelligence/src/index.ts Exposes new evidence normalization and adjudication APIs.
packages/intelligence/src/evidence/validation.ts Adds adjudication-level semantic validation (mission/claim boundaries, anchors, context identity, excerpt mechanics).
packages/intelligence/src/evidence/validation.test.ts Adds tests covering candidate→adjudication→ledger acceptance and rejection cases.
packages/intelligence/src/evidence/sufficiency.ts Tracks candidate counts in sufficiency snapshots.
packages/intelligence/src/evidence/source-ledger.ts Separates candidate acceptance from ledger assertion/source creation via validated adjudications; tracks failed missions.
packages/intelligence/src/evidence/normalization.ts Updates source ID generation to be namespace-based rather than provider-based.
packages/intelligence/src/evidence/adjudication.ts Adds model-driven adjudicator + prompt shaping and a no-op adjudicate wrapper when absent.
packages/intelligence/src/compass/calculate.ts Improves article signal excerpt selection and adds context-assisted compass projection.
packages/contracts/src/report.ts Introduces an enum schema for bias technique values to constrain report signal shape.
packages/contracts/src/index.ts Tightens documentation around search-summary limitations (no support/contradiction/qualification).
packages/contracts/src/evidence.ts Adds EvidenceCandidate + EvidenceAdjudication types and enriches ledger snapshot/validation fields.
packages/contracts/src/events.ts Adds candidateCount to research.progress payload schema.
docs/architecture-v2.md Updates architecture doc to reflect candidate/adjudication boundary and durable-first journaling/replay.
apps/extension/src/storage/stores.test.ts Adds durability/idempotency tests for job cursor vs journal row ordering and gap rejection.
apps/extension/src/storage/job-store.ts Replaces appendEvent with commitEvent to journal-before-cursor with idempotent same-sequence retries.
apps/extension/src/storage/job-journal.ts Persists events more reliably and adds point lookup for idempotent commit repair.
apps/extension/src/runtime/background-controller.ts Uses commitEvent, adds hasMore pagination signal, and removes unsafe cursor advancement on failures.
apps/extension/src/providers/exa-evidence.ts Emits candidates (not assertions), streams batches, adds deadline-based abort handling, and caches validated candidate shapes.
apps/extension/src/providers/evidence.test.ts Updates provider tests to assert candidate-only outputs and absence of assertion fields.
apps/extension/src/providers/chatgpt-evidence.ts Emits a global-search candidate batch, validates cached batch shape, and yields a failed batch instead of throwing on non-abort errors.
apps/extension/entrypoints/sidepanel/SettingsScreen.tsx Makes preference updates awaitable and shows explicit save/saved/error status messaging.
apps/extension/entrypoints/sidepanel/report-state.ts Extends research progress state with candidateCount.
apps/extension/entrypoints/sidepanel/report-state.test.ts Updates reducer tests for candidateCount.
apps/extension/entrypoints/sidepanel/ChatGptConnection.tsx Makes ChatGPT polling resilient to transient errors and handles device expiration explicitly.
apps/extension/entrypoints/sidepanel/App.tsx Adds section-level store subscriptions to reduce rerenders and makes preference saving transactional.
apps/extension/entrypoints/sidepanel/api.ts Implements paginated contiguous replay with out-of-order buffering and explicit gap detection.
apps/extension/entrypoints/sidepanel/api.test.ts Adds tests for missing-range replay before applying out-of-order live events.
apps/extension/entrypoints/sidepanel/AnalysisProgress.test.ts Updates progress UI tests to include candidateCount.
apps/extension/entrypoints/offscreen/main.ts Wires model adjudicator into job + retry runs and updates provider test plumbing for candidates.
Review details
  • Files reviewed: 40/40 changed files
  • Comments generated: 3
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment on lines +38 to +45
function containsSubject(haystack: string, subject: string): boolean {
const source = normalizeEvidenceText(haystack).toLocaleLowerCase("en-US");
const expected = words(subject);
return (
expected.length > 0 &&
expected.filter((word) => source.includes(word)).length >= expected.length
);
}
Comment on lines +11 to +15
* Start bounded work in priority order and yield each completed task as soon
* as it settles. Returning from the consumer aborts the scheduler so a
* provider can cancel in-flight network work instead of waiting for the
* entire queue to drain.
*/
Comment on lines +160 to +168
const total = Math.max(1, context.signals.length);
return CompassResultSchema.parse({
...article,
label: placement,
displayLabel: display[placement],
score,
confidenceScore: Math.min(0.86, Math.round((article.confidenceScore * 0.7 + 0.16) * 100) / 100),
confidence:
article.confidenceScore >= 0.7 ? "high" : article.confidenceScore >= 0.42 ? "medium" : "low",

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 15

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/intelligence/src/pipeline.ts (1)

167-181: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The pipeline keeps a second candidate collection that diverges from the ledger.

The ledger is already the authoritative candidate store. EvidenceLedger.accept deduplicates by candidate.id and caps storage at Math.max(24, budget.maxSources * 4). The pipeline ignores that store and builds a parallel array with candidates.push(...progress.batch.candidates), which applies neither the deduplication nor the cap. Every downstream inconsistency follows from this duplication.

Three concrete consequences:

  • The array passed to adjudicateEvidence is unbounded. EvidenceCandidate permits content up to 20,000 characters and discoveryContext up to 20,000 characters, and each batch carries up to 32 candidates. Across many missions the adjudicator prompt grows without limit, which risks a token-limit failure and uncontrolled cost.
  • Candidates that the ledger dropped at its cap are still adjudicated. acceptAdjudications then rejects those decisions with the reason "The adjudicator referenced a candidate that was not retrieved." The reason is wrong. The candidate was retrieved and then dropped by the ledger cap. The model work is wasted and the diagnostic misleads.
  • Duplicate candidate ids appearing in more than one batch are adjudicated more than once.

Reading ledger.getCandidates() at the adjudication step removes all three, and it makes the emitted candidateCount match the set that is actually adjudicated.

Per-site changes:

  • packages/intelligence/src/pipeline.ts#L167-L181: delete the local candidates array and the candidates.push(...) call. Pass candidates: ledger.getCandidates() to adjudicateEvidence at line 201. Skip the adjudication phase when that array is empty, so an honestly empty run does not issue a model call with zero candidates. If you keep a local array instead, annotate it as const candidates: EvidenceCandidate[] = []; the bare [] relies on evolving-any inference and provides no type safety at the push site.
  • packages/intelligence/src/pipeline.ts#L315-L329: apply the same change in the retry path. Delete the local array and pass artifacts.ledger.getCandidates() to adjudicateEvidence at line 349.
  • packages/intelligence/src/retrieval/coordinator.ts#L55-L62: rename the field to ledgerCandidateCount, or set it from batch.candidates.length, so a per-batch progress record does not carry a cumulative ledger total under a per-batch name.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/pipeline.ts` around lines 167 - 181, The pipeline
should use the ledger as the sole candidate source. In
packages/intelligence/src/pipeline.ts#L167-L181, remove the local candidates
array and push, pass ledger.getCandidates() to adjudicateEvidence, and skip
adjudication when empty; apply the same change in the retry path at
packages/intelligence/src/pipeline.ts#L315-L329 using
artifacts.ledger.getCandidates(). In
packages/intelligence/src/retrieval/coordinator.ts#L55-L62, rename the progress
field to ledgerCandidateCount or populate it from batch.candidates.length so its
meaning matches the per-batch record.
🟡 Minor comments (6)
apps/extension/entrypoints/sidepanel/SettingsScreen.tsx-120-127 (1)

120-127: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle synchronous onChange exceptions.

onChange permits a synchronous implementation, but onChange(next) executes before Promise.resolve is created. If it throws, the error handler does not run and saveStatus remains "saving".

Proposed fix
-    void Promise.resolve(onChange(next)).then(
+    void Promise.resolve()
+      .then(() => onChange(next))
+      .then(
       () => setSaveStatus("saved"),
       () => setSaveStatus("error"),
-    );
+      );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/entrypoints/sidepanel/SettingsScreen.tsx` around lines 120 -
127, Update savePreferences so synchronous exceptions from onChange(next) are
captured by the same failure path as rejected promises, ensuring
setSaveStatus("error") runs instead of leaving the status at "saving". Wrap the
onChange invocation in a promise-safe boundary while preserving the existing
saved and error status transitions.
apps/extension/entrypoints/sidepanel/ChatGptConnection.tsx-88-96 (1)

88-96: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear the stale local error when the authorization expires.

If a transient poll failure already set error, Line 92 updates only auth.error. The returned value uses error ?? auth.error, so the stale transient message masks the expiration message. Clear the local error in this branch.

Proposed fix
         if (Date.now() >= device.expiresAt) {
+          setError(null);
           setAuth((current) => ({
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/entrypoints/sidepanel/ChatGptConnection.tsx` around lines 88 -
96, In the device expiration branch of the authorization polling flow, clear the
local transient error state before returning so the expiration message is
displayed. Update the branch around setAuth, setDevice, and setConnecting to
reset the local error alongside the existing auth status and cleanup.
packages/contracts/src/evidence.ts-60-68 (1)

60-68: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add a refinement to EvidenceContextSignalSchema so score and direction cannot disagree.

EvidenceContextSignal is stored on adjudication assertions, but its score and direction fields are independent. Production validation rejects mismatched context signals, while compass projection uses score for weighting and labeling. Add a single refinement so an accepted signal cannot have score: -2 with direction: "right" or other inconsistent values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/src/evidence.ts` around lines 60 - 68, The
EvidenceContextSignalSchema currently permits inconsistent score and direction
values. Add a single refinement to EvidenceContextSignalSchema that validates
direction against the sign of score, rejecting mismatches while preserving the
existing field constraints and accepting only semantically aligned signals.
apps/extension/src/providers/chatgpt-evidence.ts-110-110 (1)

110-110: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A passed deadline now produces a reported provider failure.

The minimum of 1_000 ms means the call still runs when plan.deadlineAt has already passed. The web-search request cannot complete in one second, so it times out. The catch block at Lines 161-180 then emits status: "failed" with the timeout message and records a failed diagnostic.

The PR objectives distinguish completed-empty from failed states, and restrict targeted retries to failed provider lanes. A deadline overrun is not a provider failure. It marks this lane as retryable and shows an error to the reader.

Return early with a completed-empty batch when the remaining budget is too small to be usable.

🐛 Proposed guard
       if (plan.missions.length === 0) return;
+      const remainingMs = plan.deadlineAt - Date.now();
+      if (remainingMs < 5_000) {
+        yield {
+          missionId: "global-search",
+          provider: "chatgpt",
+          candidates: [],
+          coveredMissionIds: plan.missions.map((mission) => mission.id),
+          status: "completed",
+          error: null,
+          searched: false,
+          cacheHit: false,
+          durationMs: Date.now() - startedAt,
+        };
+        return;
+      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/providers/chatgpt-evidence.ts` at line 110, Update the
deadline handling in the provider flow around totalMs so an already passed
deadline or unusably small remaining budget returns a completed-empty batch
immediately, without starting the web-search request or entering the failure
catch path. Preserve normal execution when sufficient time remains and use the
existing batch/status conventions for completed-empty results.
apps/extension/src/providers/chatgpt-evidence.ts-152-152 (1)

152-152: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A cache write failure discards a successful search.

this.persistentCache?.set runs inside the try block and before the batch is yielded. If the write rejects, for example on a storage quota error, control moves to the catch at Line 161. The provider then reports outcome: "failed" and yields an empty failed batch, and every candidate from the completed search is lost.

Isolate the cache write so a persistence error cannot fail the retrieval.

🐛 Proposed fix
-      await this.persistentCache?.set(cacheKey, batches, 30 * 60_000);
+      await this.persistentCache?.set(cacheKey, batches, 30 * 60_000).catch(() => undefined);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/providers/chatgpt-evidence.ts` at line 152, In the
retrieval flow around the cache write in the provider method, isolate the
optional `persistentCache.set` failure from the surrounding search `try`/`catch`
so a rejected cache write cannot enter the failure path. Preserve the completed
batches and continue yielding them even when persistence fails, while retaining
the existing search-error handling.
docs/architecture-v2.md-68-68 (1)

68-68: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the spelling of "quoteable".

Use "quotable" instead of "quoteable".

✏️ Proposed fix
-`search-summary` candidates with no quoteable excerpt; they can only become clearly labeled
+`search-summary` candidates with no quotable excerpt; they can only become clearly labeled
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/architecture-v2.md` at line 68, In the “search-summary” documentation
text, replace the misspelled word “quoteable” with “quotable” while leaving the
surrounding wording unchanged.

Source: Linters/SAST tools

🧹 Nitpick comments (20)
apps/extension/entrypoints/sidepanel/App.tsx (1)

711-714: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the leftover commented-out legacy markup.

This block is dead code. It also references field-access patterns (state.compass.status, finding.excerpt) that no longer match the new section-based access pattern (section.status, finding.excerpt via ConnectedBias). Keeping it risks confusing a future reader into thinking these paths are still valid.

♻️ Proposed removal
-        {/* Legacy section markup removed in favor of connected sections.
-                {state.compass.status === "error" ? "Unavailable" : "Finding placement…"}
-                      <ProgressiveText text={`"${finding.excerpt}"`} />
-        */}
-
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/entrypoints/sidepanel/App.tsx` around lines 711 - 714, Remove
the leftover commented-out legacy section markup near the connected sections in
App, including the obsolete state.compass.status and
ProgressiveText/finding.excerpt references; leave the active connected-section
implementation unchanged.
apps/extension/src/storage/job-store.ts (1)

230-232: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse terminalStatus in setUnsafe.

setUnsafe at Line 65 repeats the same four-status list inline. Call terminalStatus(parsed.status) there so the terminal definition has one source.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/storage/job-store.ts` around lines 230 - 232, Update
setUnsafe to replace its inline terminal-status list with a call to
terminalStatus(parsed.status), reusing the existing helper as the single source
for terminal status definitions.
apps/extension/src/runtime/background-controller.ts (1)

725-733: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Publish analysis.eventDelta before analysis.jobChanged.

For a terminal event, the current order publishes the terminal jobChanged first. In api.ts, a terminal jobChanged starts a replay. That replay runs before the delta for the same event arrives, so the side panel fetches the terminal event from the journal and then discards the delta as stale.

The result is correct but adds a replay round trip for every terminal event. Publish the delta first so the in-band event settles the stream and the terminal jobChanged replay finds nothing to do.

♻️ Proposed reordering
-          await this.publish({ type: "analysis.jobChanged", job: committed.job });
           await this.publish({
             type: "analysis.eventDelta",
             jobId: request.jobId,
             runToken: request.runToken,
             revision: committed.job.revision,
             sequence: request.sequence,
             event: request.event,
           });
+          await this.publish({ type: "analysis.jobChanged", job: committed.job });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/runtime/background-controller.ts` around lines 725 - 733,
In the terminal-event publish flow, reorder the calls so analysis.eventDelta is
published before analysis.jobChanged. Keep the existing payloads and await
behavior unchanged, ensuring the in-band delta settles before the terminal
jobChanged can trigger replay.
apps/extension/src/storage/stores.test.ts (2)

26-32: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Derive the job key from the shared prefix.

The double hardcodes "perspectica.jobs.v1.job-1". Match the key by suffix, or import the job key prefix from the storage module, so a prefix change does not silently change what this test exercises.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/storage/stores.test.ts` around lines 26 - 32, Update the
override set method’s failNextJobWrite condition to derive the job key from the
shared storage prefix or match the job suffix instead of hardcoding
"perspectica.jobs.v1.job-1"; preserve the existing one-shot failure behavior for
the targeted job write.

241-267: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the run-token and payload mismatch rejections.

This test covers the new-row gap branch. Two rejection branches in commitEvent remain uncovered: a wrong runToken for the job, and a stored journal row whose payload differs from the resubmitted event. Both silently drop events if they regress. Add one case for each.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/storage/stores.test.ts` around lines 241 - 267, Add
coverage in the stores tests for the two uncovered rejection paths in
JobStore.commitEvent: reject an event with a runToken that differs from the
job’s run token, and reject a resubmission whose payload differs from the stored
journal row. Assert each event is not accepted and is not silently appended or
altered, using the existing event setup and journal inspection patterns.
apps/extension/entrypoints/sidepanel/api.ts (2)

219-232: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Flatten the drain loop and prune superseded buffer entries.

Two points in this block:

  1. applyEnvelope(next, true) recurses, and the nested call runs its own drain loop. The outer while then finds nothing left. The drain is therefore redundant, and stack depth grows with the buffered run length.
  2. Entries buffered in pendingEnvelopes are removed only when the drain reaches them. When replay delivers those sequences instead, the stale entries stay in the map for the lifetime of the stream.

An iterative drain that deletes every entry at or below deliveredSequence resolves both.

♻️ Proposed iterative drain
-    deliveredSequence = envelope.sequence;
-    onEvent(envelope.event);
-    finishFromEvent(envelope.event);
-    while (!settled) {
-      const next = pendingEnvelopes.get(deliveredSequence + 1);
-      if (!next) break;
-      pendingEnvelopes.delete(next.sequence);
-      applyEnvelope(next, true);
-    }
+    let current: StreamEnvelope | undefined = envelope;
+    while (current && !settled) {
+      pendingEnvelopes.delete(current.sequence);
+      deliveredSequence = current.sequence;
+      onEvent(current.event);
+      finishFromEvent(current.event);
+      current = pendingEnvelopes.get(deliveredSequence + 1);
+    }
+    for (const sequence of [...pendingEnvelopes.keys()])
+      if (sequence <= deliveredSequence) pendingEnvelopes.delete(sequence);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/entrypoints/sidepanel/api.ts` around lines 219 - 232, Refactor
the envelope handling around applyEnvelope so buffered envelopes are drained
iteratively without recursively calling applyEnvelope from the drain loop. After
advancing deliveredSequence, repeatedly remove and process the next pending
envelope, and prune any pendingEnvelopes entries whose sequence is at or below
deliveredSequence, including entries superseded by replay delivery.

164-171: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Reuse the contract envelope type instead of defining StreamEnvelope locally.

@perspectica/contracts/events already defines AnalysisEnvelope with protocol, jobId, runToken, sequence, revision, and event. The sidepanel only uses the envelope fields after stripping protocol, so derive the local type from AnalysisEnvelope rather than duplicating the shape.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/entrypoints/sidepanel/api.ts` around lines 164 - 171, Replace
the local StreamEnvelope definition near pendingEnvelopes with a type derived
from the existing AnalysisEnvelope contract in `@perspectica/contracts/events`,
omitting the protocol field while retaining jobId, runToken, sequence, revision,
and event. Update references as needed so pendingEnvelopes uses this derived
contract type without duplicating the envelope shape.
apps/extension/src/storage/job-journal.ts (1)

46-58: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Bound the write-through event cache.

appendEvent now caches every persisted envelope, not only the no-IndexedDB fallback rows. eventMemory is pruned only by clearEvents for a job id. A long run keeps every envelope resident in addition to the IndexedDB copy.

getEvent reads only a single recent sequence during commit repair. A small bounded cache, or caching only the most recent sequences, gives the same repair behavior with a fixed memory ceiling.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/storage/job-journal.ts` around lines 46 - 58, Update
appendEvent in the eventMemory write-through path so it retains only a bounded
set of recent envelopes instead of caching every persisted event. Enforce the
fixed-size limit after both IndexedDB writes and fallback writes, while
preserving getEvent’s ability to repair recent sequences and existing
clearEvents behavior.
apps/extension/entrypoints/offscreen/main.ts (1)

203-206: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

sourceCount now counts candidates, not sources.

The rename to first.value.candidates.length is correct for the new EvidenceBatch shape. The surrounding vocabulary no longer matches. This PR establishes that a candidate is provider discovery and a source is adjudicated evidence. The returned field is still sourceCount, and the error text at line 205 still says "did not return a web source."

testSearchProvider is a connectivity probe, so the behavior is correct. Rename the field to candidateCount and adjust the message so the probe uses the same terms as the rest of the pipeline. The return type is declared at line 166 and consumed by the command caller, so update both.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/entrypoints/offscreen/main.ts` around lines 203 - 206, Update
testSearchProvider and its declared return type to use candidateCount instead of
sourceCount, and update the command caller to consume the renamed field. Change
the zero-candidate error message to refer to a web candidate rather than a
source, preserving the existing connectivity-probe behavior.
packages/intelligence/src/evidence/source-ledger.ts (2)

130-130: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the injected clock for retrievedAt.

new Date().toISOString() bypasses the now function that AnalysisInput and TargetedRetryInput already thread through the pipeline. Snapshots become nondeterministic, so tests cannot assert on retrievedAt. Accept a clock on the ledger and use it here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/evidence/source-ledger.ts` at line 130, Update the
ledger construction and snapshot creation flow around retrievedAt to accept the
injected clock already provided by AnalysisInput and TargetedRetryInput, then
call that clock instead of new Date().toISOString(). Ensure retrievedAt remains
an ISO timestamp while using the shared now function for deterministic
snapshots.

222-230: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

hasEvidenceForSection and isSufficient build a full validated snapshot.

snapshot() runs evaluateSufficiency, buildEvidenceGraph, and a complete SourceLedgerSnapshotSchema.parse over up to 64 sources, 96 assertions, 256 nodes, and 512 edges. Both accessors call it to read one value.

If a caller checks all seven report sections, this performs seven graph builds and seven full zod parses. Read the underlying maps directly instead.

♻️ Proposed refactor: avoid snapshot construction in accessors
   hasEvidenceForSection(section: ReportSection): boolean {
-    return this.snapshot().servedSections[section]?.length > 0;
+    return [...this.assertions.values()].some((assertion) => {
+      const mission = this.plan.missions.find((value) => value.id === assertion.missionId);
+      return mission?.canServeSections.includes(section) ?? false;
+    });
   }

isSufficient can call evaluateSufficiency directly rather than through snapshot():

   isSufficient(): boolean {
-    return this.snapshot().sufficiency.stop;
+    return evaluateSufficiency(
+      this.plan,
+      [...this.assertions.values()],
+      this.completedMissionCount(),
+      this.budget,
+      this.candidates.size,
+    ).stop;
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/evidence/source-ledger.ts` around lines 222 - 230,
Update isSufficient and hasEvidenceForSection to avoid constructing the full
validated snapshot for single-value reads: have isSufficient call
evaluateSufficiency directly, and have hasEvidenceForSection read the underlying
served-sections map or equivalent ledger state directly. Preserve their existing
boolean results while avoiding repeated buildEvidenceGraph and
SourceLedgerSnapshotSchema.parse work; leave hasEvidenceForClaim unchanged.
packages/intelligence/src/pipeline.ts (1)

194-211: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Adjudication rejections are discarded and produce no telemetry.

ledger.acceptAdjudications(decisions) returns a LedgerAcceptResult carrying rejected and reasons. Line 206 ignores it. Line 354 in the retry path does the same.

This PR adds adjudication as the gate that prevents provider URLs from reaching the reader. When that gate drops a decision, nothing records it. An operator cannot distinguish "the providers found nothing" from "the adjudicator produced 20 decisions and validation rejected all 20". The ledger.updated event emitted at lines 207-211 reports only the surviving counts.

Emit the rejection count and reasons through onTelemetry.

📊 Proposed change: record the adjudication outcome
-    ledger.acceptAdjudications(decisions);
+    const adjudicationResult = ledger.acceptAdjudications(decisions);
+    input.onTelemetry?.({
+      level: adjudicationResult.rejected > 0 ? "warn" : "info",
+      scope: "evidence.adjudication",
+      event: "decisions.accepted",
+      message: `Adjudication accepted ${adjudicationResult.acceptedAssertions} of ${decisions.length} decisions.`,
+      payload: {
+        decisionCount: decisions.length,
+        rejected: adjudicationResult.rejected,
+        reasons: adjudicationResult.reasons,
+      },
+    });

Adjust the telemetry object to match the PipelineTelemetry shape in this package.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/pipeline.ts` around lines 194 - 211, Capture the
LedgerAcceptResult returned by ledger.acceptAdjudications in both the main
adjudication flow and the retry path, then report its rejected count and reasons
through onTelemetry using the package’s PipelineTelemetry shape. Update the
related ledger.updated telemetry so adjudication rejections are observable
alongside the surviving source and assertion counts.
packages/intelligence/src/retrieval/coordinator.ts (1)

55-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

candidateCount reports a ledger total, not a batch count.

The field sits on a per-batch progress record next to batch, but it holds options.ledger.getCandidates().length, which is the cumulative deduplicated and capped ledger total. packages/intelligence/src/pipeline.ts forwards this value into research.progress while separately accumulating progress.batch.candidates into an unbounded local array. The two counts diverge.

Rename the field to ledgerCandidateCount, or derive it from batch.candidates.length, so the emitted progress number matches the set that is actually adjudicated. This shares a root cause with the pipeline accumulator; see the consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/retrieval/coordinator.ts` around lines 55 - 62,
Update the per-batch progress record around the generator yielding batch data so
the candidate count reflects the current adjudicated batch, either by renaming
the ledger total field to ledgerCandidateCount or by deriving it from
batch.candidates.length; keep the ledger total distinct from the batch candidate
count and align pipeline consumers accordingly.
packages/intelligence/src/pipeline.test.ts (1)

97-118: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the rejection paths this PR introduces.

The adjudicator double always produces an accepted decision. Four new behaviors stay untested.

  1. Excerpt mismatch. Line 110 sets excerpt: candidate.content, and line 78 makes content identical to the candidate content. Any excerpt-containment check in validateEvidenceAdjudication passes trivially. The rejection branch never runs.
  2. Unknown candidate id. acceptAdjudications rejects a decision whose candidateId is not in the ledger. No test emits a decision with an unrecognized id.
  3. Context signals. Line 113 always sets context: null. The new EvidenceContextSignalSchema and the context field on EvidenceAssertion are never populated.
  4. Failed-mission retry. The empty-run test at lines 224-263 asserts that a completed-empty run performs no retry retrieval. The positive counterpart is missing: a run whose missions are marked failed must perform retry retrieval.

Item 1 matters most. Preventing an unverifiable excerpt from reaching the reader is the stated purpose of this change.

#!/bin/bash
# Description: Check existing coverage for adjudication rejection and retry-after-failure.
set -euo pipefail

echo "== validation tests =="
fd -t f 'validation.test.ts' packages/intelligence/src | xargs -r rg -n -C 5 'excerpt|claimRelevant|relationshipChecked|candidateId'

echo "== retry-after-failure coverage =="
rg -n -C 8 'retryArticleSections' packages/intelligence/src --glob '*.test.ts'

echo "== candidateCount assertions =="
rg -n -C 4 'candidateCount' packages/intelligence/src packages/contracts/src apps/extension --glob '*.test.ts'

Also applies to: 224-263

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/pipeline.test.ts` around lines 97 - 118, Extend the
tests around the EvidenceAdjudicator double and the empty-run coverage to
exercise all new rejection and retry paths: return a non-contained excerpt to
verify validateEvidenceAdjudication rejects it, emit an unknown candidateId to
verify acceptAdjudications rejects it, populate context with a valid
EvidenceContextSignalSchema value, and add a failed-mission run asserting
retryArticleSections performs retrieval. Keep the existing accepted-decision and
completed-empty no-retry assertions intact.
packages/contracts/src/evidence.ts (1)

42-57: 📐 Maintainability & Code Quality | 🔵 Trivial

Migrate these schemas to Zod 4 top-level string formats.

packages/contracts uses zod@4, so replace the deprecated method-style formats consistently across the file: z.string().url() → z.url(), and z.string().datetime({ offset: true }) → z.iso.datetime({ offset: true }).

Confirm that z.url() is acceptable for sourceUrl, canonicalUrl, and url because its WHATWG URL validation behavior can differ from the old regex-based validation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/contracts/src/evidence.ts` around lines 42 - 57, Update all affected
schemas in packages/contracts to use Zod 4 top-level formats: replace
z.string().url() with z.url() and z.string().datetime({ offset: true }) with
z.iso.datetime({ offset: true }). Apply this consistently to sourceUrl,
canonicalUrl, and url fields, and verify z.url() preserves the intended WHATWG
URL validation behavior for each.
packages/intelligence/src/planning/lens.ts (1)

168-173: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Replace the constructed regular expression with a case-insensitive substring test.

The excerpt is escaped, so the pattern is a literal string and no backtracking risk exists. The regular expression adds no matching power over a substring check, and it allocates a new RegExp for every bias signal. A substring test is simpler, faster, and removes the dependency on keeping the escape set correct. It also clears the regexp-non-literal-typescript static analysis warning.

♻️ Proposed refactor
       const excerpt = signal.excerpt.trim();
-      if (
-        !excerpt ||
-        !new RegExp(excerpt.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i").test(paragraph.text)
-      )
-        return [];
+      if (
+        !excerpt ||
+        !paragraph.text
+          .toLocaleLowerCase("en-US")
+          .includes(excerpt.toLocaleLowerCase("en-US"))
+      )
+        return [];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/planning/lens.ts` around lines 168 - 173, In the
paragraph filtering logic around signal.excerpt, replace the escaped RegExp
construction and test with a case-insensitive substring check. Preserve the
existing empty-excerpt behavior and return [] when paragraph.text does not
contain the excerpt, without allocating a RegExp.

Source: Linters/SAST tools

packages/intelligence/src/evidence/adjudication.ts (1)

50-56: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Bound the candidate section by characters, not only by count.

Each candidate contributes up to about 6,300 characters (4,000 content + 2,000 discovery + title). The cap Math.max(input.budget.maxSources * 4, 24) bounds only the number of candidates. With 24 candidates the prompt already reaches roughly 150,000 characters, and it grows linearly with budget.maxSources. A single generateText call with a 45-second deadline can then exceed the model context window or time out.

Add a running character budget so the prompt stops adding candidates after a fixed total.

♻️ Proposed refactor to add a total character budget
-  const candidates = input.candidates
-    .slice(0, Math.max(input.budget.maxSources * 4, 24))
-    .map(
-      (candidate) =>
-        `CANDIDATE ${candidate.id} mission=${candidate.missionId ?? "global-search"} url=${candidate.sourceUrl} title=${compact(candidate.title, 300)} kind=${candidate.contentKind} sourceType=${candidate.sourceType}\nCONTENT: ${compact(candidate.content, 4_000)}\nDISCOVERY: ${compact(candidate.discoveryContext ?? "", 2_000)}`,
-    )
-    .join("\n\n");
+  const MAX_CANDIDATE_CHARACTERS = 60_000;
+  const renderedCandidates: string[] = [];
+  let usedCharacters = 0;
+  for (const candidate of input.candidates.slice(
+    0,
+    Math.max(input.budget.maxSources * 4, 24),
+  )) {
+    const rendered = `CANDIDATE ${candidate.id} mission=${candidate.missionId ?? "global-search"} url=${candidate.sourceUrl} title=${compact(candidate.title, 300)} kind=${candidate.contentKind} sourceType=${candidate.sourceType}\nCONTENT: ${compact(candidate.content, 4_000)}\nDISCOVERY: ${compact(candidate.discoveryContext ?? "", 2_000)}`;
+    if (usedCharacters + rendered.length > MAX_CANDIDATE_CHARACTERS) break;
+    usedCharacters += rendered.length;
+    renderedCandidates.push(rendered);
+  }
+  const candidates = renderedCandidates.join("\n\n");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/evidence/adjudication.ts` around lines 50 - 56,
Update the candidate-building flow around the candidates map so it enforces a
fixed total character budget in addition to the existing candidate-count limit.
Track accumulated rendered candidate length, stop adding candidates once the
budget is reached, and preserve the current formatting and per-candidate compact
limits for included candidates.
packages/intelligence/src/evidence/validation.test.ts (1)

251-272: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for accepted context missions.

Every context test asserts rejection. No test drives a journalist-context, publication-context, or missing-background mission to an accepted result. Those are the paths where the subject matching and the mandatory-context rules apply.

Two defects raised on packages/intelligence/src/evidence/validation.ts sit on exactly those paths, and the current suite would not detect either one:

  • containsSubject returns false for any subject whose words are all shorter than 4 characters. Add a case with publication: "NPR".
  • A missing-background mission rejects every decision that carries no context signal. Add a case with purpose: "missing-background" and context: null.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/evidence/validation.test.ts` around lines 251 -
272, Add accepted-result coverage in validation.test.ts for journalist-context
or publication-context using publication "NPR", ensuring subject matching
accepts the short subject, and for a missing-background mission with context:
null, ensuring mandatory-context validation accepts it. Assert both decisions
are accepted and exercise the relevant mission-specific paths in
validateEvidenceAdjudication.
packages/intelligence/src/planning/lens.test.ts (1)

92-146: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test that retains a bias signal.

Both tests assert that grounded.bias is empty. A regression that drops every bias signal would still pass. Add a case with a bias signal whose excerpt is an exact substring of its own paragraph, and assert that the signal survives.

The fixture also supports a positive attribution case. A signal on p2/s2 should produce attributed: true, because s2 has a speaker, an attribution verb, and isQuoted: true. That locks in the signalAttribution behavior added in packages/intelligence/src/planning/lens.ts.

💚 Proposed test
+  it("retains a grounded bias signal and recomputes attribution from the sentence", () => {
+    const grounded = groundedArticleSignals(
+      {
+        compass: [],
+        bias: [
+          {
+            id: "bias-3",
+            technique: "word-choice",
+            paragraphId: "p2",
+            sentenceId: "s2",
+            excerpt: "the proposal is affordable",
+            explanation: "Attributed evaluative wording.",
+            confidence: 0.8,
+            attributed: false,
+          },
+        ],
+      },
+      article(),
+    );
+    expect(grounded.bias).toHaveLength(1);
+    expect(grounded.bias[0]).toMatchObject({
+      paragraphId: "p2",
+      sentenceId: "s2",
+      attributed: true,
+    });
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/planning/lens.test.ts` around lines 92 - 146, Add a
positive case to the “article signal grounding” tests using a bias signal on
p2/s2 whose excerpt exactly matches text in that paragraph, then assert
grounded.bias retains the signal and sets attributed to true. Keep the existing
rejection cases unchanged and exercise the signalAttribution behavior in
lens.ts.
packages/intelligence/src/synthesis/perspective.ts (1)

57-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the duplicate contextSources map.

contextSources (line 58) rebuilds the exact same Map as journalistSources (line 30): new Map(ledger.getSources().map((source) => [source.id, source])). Reuse journalistSources instead of calling ledger.getSources() and constructing a second identical map.

♻️ Proposed fix
   const contextAssertions = ledger.getAssertions().filter((assertion) => assertion.context);
-  const contextSources = new Map(ledger.getSources().map((source) => [source.id, source]));
+  const contextSources = journalistSources;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/synthesis/perspective.ts` around lines 57 - 58,
Remove the duplicate contextSources Map construction and reuse the existing
journalistSources map from the synthesis flow when resolving context sources.
Update references to contextSources accordingly, while preserving the current
source lookup behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ce0f9ed-f853-4656-8904-b0074454bab3

📥 Commits

Reviewing files that changed from the base of the PR and between ee18510 and adcc05f.

📒 Files selected for processing (40)
  • README.md
  • apps/extension/entrypoints/offscreen/main.ts
  • apps/extension/entrypoints/sidepanel/AnalysisProgress.test.ts
  • apps/extension/entrypoints/sidepanel/App.tsx
  • apps/extension/entrypoints/sidepanel/ChatGptConnection.tsx
  • apps/extension/entrypoints/sidepanel/SettingsScreen.tsx
  • apps/extension/entrypoints/sidepanel/api.test.ts
  • apps/extension/entrypoints/sidepanel/api.ts
  • apps/extension/entrypoints/sidepanel/report-state.test.ts
  • apps/extension/entrypoints/sidepanel/report-state.ts
  • apps/extension/src/providers/chatgpt-evidence.ts
  • apps/extension/src/providers/evidence.test.ts
  • apps/extension/src/providers/exa-evidence.ts
  • apps/extension/src/runtime/background-controller.ts
  • apps/extension/src/storage/job-journal.ts
  • apps/extension/src/storage/job-store.ts
  • apps/extension/src/storage/stores.test.ts
  • docs/architecture-v2.md
  • packages/contracts/src/events.ts
  • packages/contracts/src/evidence.ts
  • packages/contracts/src/index.ts
  • packages/contracts/src/report.ts
  • packages/intelligence/src/compass/calculate.ts
  • packages/intelligence/src/evidence/adjudication.ts
  • packages/intelligence/src/evidence/normalization.ts
  • packages/intelligence/src/evidence/source-ledger.ts
  • packages/intelligence/src/evidence/sufficiency.ts
  • packages/intelligence/src/evidence/validation.test.ts
  • packages/intelligence/src/evidence/validation.ts
  • packages/intelligence/src/index.ts
  • packages/intelligence/src/pipeline.test.ts
  • packages/intelligence/src/pipeline.ts
  • packages/intelligence/src/planning/lens.test.ts
  • packages/intelligence/src/planning/lens.ts
  • packages/intelligence/src/report/projector.test.ts
  • packages/intelligence/src/report/projector.ts
  • packages/intelligence/src/retrieval/coordinator.ts
  • packages/intelligence/src/retrieval/scheduler.ts
  • packages/intelligence/src/retrieval/types.ts
  • packages/intelligence/src/synthesis/perspective.ts

Comment on lines +244 to +270
while (!settled && hasMore) {
const response = await sendRuntimeRequest<{
jobId: string;
lastSequence: number;
hasMore?: boolean;
events: StreamEnvelope[];
complete: boolean;
}>({ type: "analysis.getEventsSince", jobId: job.id, lastSequence: deliveredSequence });
const events = [...response.events].sort((left, right) => left.sequence - right.sequence);
if (
events.length === 0 &&
response.lastSequence > deliveredSequence &&
!response.complete
)
throw new Error(
"The analysis journal has a missing event gap. Reconnect and try again.",
);
for (const envelope of events) applyEnvelope(envelope, true);
const lastReturned = events.at(-1)?.sequence ?? deliveredSequence;
hasMore = Boolean(response.hasMore) || lastReturned < response.lastSequence;
if (hasMore && events.length === 0)
throw new Error("The analysis journal could not replay its next event.");
if (!hasMore && response.lastSequence > deliveredSequence)
throw new Error(
"The analysis journal has a missing event gap. Reconnect and try again.",
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add a no-progress guard to the pagination loop.

The loop repeats while hasMore is true. hasMore becomes true when lastReturned < response.lastSequence. The throw at Line 264 only covers the case where the page is empty.

A non-empty page whose events are all discarded by applyEnvelope leaves deliveredSequence unchanged. The next request sends the same lastSequence, so the server returns the same page, and the loop repeats without delay or bound. applyEnvelope discards an envelope when envelope.runToken !== (job.runToken ?? ""), so a journal that still holds rows from an earlier run token for the same job id reaches this state.

Track deliveredSequence across iterations and stop when a page produces no advance.

🐛 Proposed progress guard
         let hasMore = true;
         while (!settled && hasMore) {
+          const before = deliveredSequence;
           const response = await sendRuntimeRequest<{
           for (const envelope of events) applyEnvelope(envelope, true);
+          if (!settled && deliveredSequence === before)
+            throw new Error(
+              "The analysis journal returned no usable events. Reconnect and try again.",
+            );
           const lastReturned = events.at(-1)?.sequence ?? deliveredSequence;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
while (!settled && hasMore) {
const response = await sendRuntimeRequest<{
jobId: string;
lastSequence: number;
hasMore?: boolean;
events: StreamEnvelope[];
complete: boolean;
}>({ type: "analysis.getEventsSince", jobId: job.id, lastSequence: deliveredSequence });
const events = [...response.events].sort((left, right) => left.sequence - right.sequence);
if (
events.length === 0 &&
response.lastSequence > deliveredSequence &&
!response.complete
)
throw new Error(
"The analysis journal has a missing event gap. Reconnect and try again.",
);
for (const envelope of events) applyEnvelope(envelope, true);
const lastReturned = events.at(-1)?.sequence ?? deliveredSequence;
hasMore = Boolean(response.hasMore) || lastReturned < response.lastSequence;
if (hasMore && events.length === 0)
throw new Error("The analysis journal could not replay its next event.");
if (!hasMore && response.lastSequence > deliveredSequence)
throw new Error(
"The analysis journal has a missing event gap. Reconnect and try again.",
);
}
let hasMore = true;
while (!settled && hasMore) {
const before = deliveredSequence;
const response = await sendRuntimeRequest<{
jobId: string;
lastSequence: number;
hasMore?: boolean;
events: StreamEnvelope[];
complete: boolean;
}>({ type: "analysis.getEventsSince", jobId: job.id, lastSequence: deliveredSequence });
const events = [...response.events].sort((left, right) => left.sequence - right.sequence);
if (
events.length === 0 &&
response.lastSequence > deliveredSequence &&
!response.complete
)
throw new Error(
"The analysis journal has a missing event gap. Reconnect and try again.",
);
for (const envelope of events) applyEnvelope(envelope, true);
if (!settled && deliveredSequence === before)
throw new Error(
"The analysis journal returned no usable events. Reconnect and try again.",
);
const lastReturned = events.at(-1)?.sequence ?? deliveredSequence;
hasMore = Boolean(response.hasMore) || lastReturned < response.lastSequence;
if (hasMore && events.length === 0)
throw new Error("The analysis journal could not replay its next event.");
if (!hasMore && response.lastSequence > deliveredSequence)
throw new Error(
"The analysis journal has a missing event gap. Reconnect and try again.",
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/entrypoints/sidepanel/api.ts` around lines 244 - 270, Update
the pagination loop around sendRuntimeRequest and applyEnvelope to track
deliveredSequence before processing each page, then detect when the page
produces no sequence advance. Stop or throw according to the existing replay
error behavior when hasMore remains true without progress, including non-empty
pages whose envelopes are discarded by applyEnvelope.

Comment on lines +835 to 868
const updatePreferences = async (next: SettingsPreferences): Promise<void> => {
if (!runtime) throw new Error("Perspectica settings are still loading.");
const previous = runtime;
const updated: ExtensionPreferences = {
...runtime.preferences,
...next,
};
setRuntime({ ...runtime, preferences: updated });
void updateExtensionPreferences(updated);
try {
const saved = await updateExtensionPreferences(updated);
setRuntime((current) => (current ? { ...current, preferences: saved } : current));
} catch (error) {
setRuntime(previous);
throw error;
}
};

const updateSearchProvider = async (provider: SearchProviderKind) => {
if (!runtime) throw new Error("Perspectica settings are still loading.");
const previous = runtime;
const test = await testSearchProvider(provider);
if (!test.available) throw new Error(`${provider} search is not available.`);
const updated = { ...runtime.preferences, searchProvider: provider };
setRuntime({ ...runtime, preferences: updated });
setProviderReady(true);
await updateExtensionPreferences(updated);
try {
const saved = await updateExtensionPreferences(updated);
setRuntime((current) => (current ? { ...current, preferences: saved } : current));
} catch (error) {
setRuntime(previous);
setProviderReady(previous.preferences.searchProvider === "chatgpt" || previous.hasExaKey);
throw error;
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Guard updatePreferences and updateSearchProvider against overlapping calls.

Both functions capture runtime as previous at call time and later call setRuntime(previous) on failure, or setRuntime with the fetched saved value on success, with no sequence check. If two calls overlap, for example a preference toggle and a search-provider change fired close together, or one call resolving slower than a later one, the later-resolving call determines the final state regardless of which call started more recently:

  • On success: an older, slower call can overwrite the state committed by a newer, faster call with stale saved data.
  • On failure: an older call's previous rollback can undo a newer call's already-applied optimistic or committed state.

Both paths silently revert or overwrite the user's most recent settings change without any visible error. The file already has prior art for this exact problem: analyze() (Line 523) guards stale completions with a monotonic runRef counter. Apply the same pattern here, shared across both functions since they both mutate runtime.preferences.

🔒 Proposed fix using a shared sequence guard
+  const preferencesRequestRef = useRef(0);
+
   const updatePreferences = async (next: SettingsPreferences): Promise<void> => {
     if (!runtime) throw new Error("Perspectica settings are still loading.");
+    const requestId = ++preferencesRequestRef.current;
     const previous = runtime;
     const updated: ExtensionPreferences = {
       ...runtime.preferences,
       ...next,
     };
     setRuntime({ ...runtime, preferences: updated });
     try {
       const saved = await updateExtensionPreferences(updated);
+      if (preferencesRequestRef.current !== requestId) return;
       setRuntime((current) => (current ? { ...current, preferences: saved } : current));
     } catch (error) {
-      setRuntime(previous);
+      if (preferencesRequestRef.current === requestId) setRuntime(previous);
       throw error;
     }
   };

   const updateSearchProvider = async (provider: SearchProviderKind) => {
     if (!runtime) throw new Error("Perspectica settings are still loading.");
+    const requestId = ++preferencesRequestRef.current;
     const previous = runtime;
     const test = await testSearchProvider(provider);
     if (!test.available) throw new Error(`${provider} search is not available.`);
     const updated = { ...runtime.preferences, searchProvider: provider };
     setRuntime({ ...runtime, preferences: updated });
     setProviderReady(true);
     try {
       const saved = await updateExtensionPreferences(updated);
+      if (preferencesRequestRef.current !== requestId) return;
       setRuntime((current) => (current ? { ...current, preferences: saved } : current));
     } catch (error) {
-      setRuntime(previous);
-      setProviderReady(previous.preferences.searchProvider === "chatgpt" || previous.hasExaKey);
+      if (preferencesRequestRef.current === requestId) {
+        setRuntime(previous);
+        setProviderReady(previous.preferences.searchProvider === "chatgpt" || previous.hasExaKey);
+      }
       throw error;
     }
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/entrypoints/sidepanel/App.tsx` around lines 835 - 868, Guard
both updatePreferences and updateSearchProvider with a shared monotonic sequence
ref, following the existing analyze() runRef pattern. Increment the shared
sequence when either function starts, and only apply optimistic updates,
successful saved preferences, rollback state, or provider readiness changes if
that call still owns the latest sequence; stale completions must not overwrite
newer preference changes.

Comment on lines 86 to +102
} catch (cause) {
if (cancelled) return;
setError(cause instanceof Error ? cause.message : "ChatGPT sign-in did not finish.");
setConnecting(false);
if (Date.now() >= device.expiresAt) {
setAuth((current) => ({
...current,
status: "expired",
error: "The one-time sign-in code expired. Start a new Login with ChatGPT flow.",
}));
setDevice(null);
setConnecting(false);
return;
}
// Polling errors are transient (the browser may briefly lose access
// to OpenAI). Keep the device authorization alive and schedule the
// next poll instead of leaving the UI permanently pending.
setError(cause instanceof Error ? cause.message : "ChatGPT sign-in is still pending.");
timer = setTimeout(poll, Math.max(1_500, device.intervalMs));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Stop retrying terminal authentication failures.

The catch block treats every error as transient until device.expiresAt. The upstream apps/extension/src/auth/chatgpt-session.ts poll can throw the terminal "ChatGPT did not return a refreshable session. Start sign-in again." error before that time. This code then keeps the UI pending and schedules more polls. Return a typed terminal outcome or classify non-retryable authentication errors before scheduling another poll.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/entrypoints/sidepanel/ChatGptConnection.tsx` around lines 86 -
102, The polling catch block in the ChatGPT connection flow must stop retrying
terminal authentication failures from the session poll. Classify the known
non-retryable refresh-session error (or use a typed terminal outcome from the
poll) before the transient-error path, update authentication state to require a
new sign-in, clear the device/connection state as appropriate, and return
without scheduling another timeout; preserve retries for genuinely transient
errors.

const [exaKey, setExaKey] = useState("");
const [providerStatus, setProviderStatus] = useState<string | null>(null);
const [cacheStatus, setCacheStatus] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Keep a local draft and serialize preference saves.

Because App.tsx:933-940 updates preferences only after persistence resolves, rapid changes can build multiple payloads from the same old snapshot. The second change can drop the first change. Older requests can also overwrite the latest saveStatus.

Maintain a local draft, serialize or coalesce writes, roll back the draft on the latest failure, and ignore stale completion statuses. Add a test that resolves two saves out of order.

Also applies to: 120-127, 161-161, 206-206, 331-331, 374-382

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/entrypoints/sidepanel/SettingsScreen.tsx` at line 49, Update
SettingsScreen’s preference editing and save flow around saveStatus and the
referenced handlers to maintain a local draft instead of deriving rapid updates
from the persisted preferences snapshot. Serialize or coalesce writes so each
change includes prior draft changes, roll back the draft only when the latest
save fails, and ignore stale request completions when updating saveStatus. Add a
test that resolves two saves out of order and verifies the latest draft and
status are preserved.

Comment on lines +123 to 138
const discoveryContext = result.text.trim().slice(0, 20_000) || null;
const candidates: EvidenceCandidate[] = unique.map(({ source, url }) => ({
id: sourceIdFor(url, "candidate"),
missionId: null,
sourceUrl: url,
title: source.title?.trim() || hostname(url),
publication: hostname(url),
publishedAt: null,
content: discoveryContext ?? `Native web search returned ${hostname(url)}.`,
contentKind: "search-summary",
sourceType: sourceType(url),
discoveryContext,
discoveryExcerpt: null,
providerScore: null,
provider: "chatgpt",
}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Every candidate shares the same aggregated content, which defeats the per-source claim anchor check.

discoveryContext is the full model response text for the whole search. Lines 131 and 134 assign that same text to content and discoveryContext for every candidate in unique. The text describes all discovered sources, not the individual URL.

claimAnchorMatches in packages/intelligence/src/evidence/validation.ts Lines 52-54 builds its haystack from candidate.content, candidate.discoveryContext, and candidate.title. Because all candidates carry identical aggregated text, a claim anchor found anywhere in the response makes every URL in the batch pass the anchor check, including URLs the model never connected to that claim.

The search-summary rule at validation.ts Line 223 blocks supports, contradicts, and qualifies, so the impact is limited to adds-context and context signals. It is still a false-grounding path, and it is the pattern this PR removes elsewhere.

Attribute per-source text where the response provides it. If the response cannot be split per source, keep content source-specific and pass the aggregated text separately so the anchor check does not treat it as source content.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/extension/src/providers/chatgpt-evidence.ts` around lines 123 - 138,
Update the candidate construction in the ChatGPT evidence discovery flow so each
candidate’s content and discoveryContext contain only text attributable to its
own source URL, rather than the shared aggregated result.text. Reuse the
response’s per-source attribution when available; otherwise keep content
source-specific using the existing fallback and pass the full aggregated
response through a separate non-source-content field that claimAnchorMatches
will not inspect.

Comment on lines 133 to 139
const previous = this.sources.get(sourceId);
if (!previous && this.sources.size >= this.budget.maxSources) continue;
if (!previous) acceptedSources += 1;
this.sources.set(sourceId, mergeSource(previous, source));
if (this.assertions.size >= MAX_EVIDENCE_ASSERTIONS) continue;
const assertionId = assertionIdFor(sourceId, card.missionId, card.statement);
const assertionId = assertionIdFor(sourceId, decision.missionId, decision.statement);
if (this.assertions.has(assertionId)) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

The assertion cap leaves orphan sources in the ledger.

The source is written at line 136 before the assertion cap is tested at line 137. When this.assertions.size >= MAX_EVIDENCE_ASSERTIONS, the method writes the SourceRecord and increments acceptedSources, then skips the assertion with continue.

The result is a SourceRecord in the snapshot that no assertion references. That record still flows into buildEvidenceGraph and into the Works Cited projection, so the reader sees a cited source with no evidence attached to it. acceptedSources also over-reports.

Test the assertion cap before writing the source.

🐛 Proposed fix: check both caps before mutating state
       const previous = this.sources.get(sourceId);
       if (!previous && this.sources.size >= this.budget.maxSources) continue;
-      if (!previous) acceptedSources += 1;
-      this.sources.set(sourceId, mergeSource(previous, source));
       if (this.assertions.size >= MAX_EVIDENCE_ASSERTIONS) continue;
       const assertionId = assertionIdFor(sourceId, decision.missionId, decision.statement);
       if (this.assertions.has(assertionId)) continue;
+      if (!previous) acceptedSources += 1;
+      this.sources.set(sourceId, mergeSource(previous, source));
       const claim = decision.claimId
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const previous = this.sources.get(sourceId);
if (!previous && this.sources.size >= this.budget.maxSources) continue;
if (!previous) acceptedSources += 1;
this.sources.set(sourceId, mergeSource(previous, source));
if (this.assertions.size >= MAX_EVIDENCE_ASSERTIONS) continue;
const assertionId = assertionIdFor(sourceId, card.missionId, card.statement);
const assertionId = assertionIdFor(sourceId, decision.missionId, decision.statement);
if (this.assertions.has(assertionId)) continue;
const previous = this.sources.get(sourceId);
if (!previous && this.sources.size >= this.budget.maxSources) continue;
if (this.assertions.size >= MAX_EVIDENCE_ASSERTIONS) continue;
const assertionId = assertionIdFor(sourceId, decision.missionId, decision.statement);
if (this.assertions.has(assertionId)) continue;
if (!previous) acceptedSources += 1;
this.sources.set(sourceId, mergeSource(previous, source));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/evidence/source-ledger.ts` around lines 133 - 139,
Check the MAX_EVIDENCE_ASSERTIONS limit before mutating state in the
source-processing flow, alongside the existing source-cap validation. Ensure
sources skipped because the assertion cap is reached are not written to
this.sources and do not increment acceptedSources; preserve existing behavior
for sources that can still accept an assertion.

Comment on lines +38 to +45
function containsSubject(haystack: string, subject: string): boolean {
const source = normalizeEvidenceText(haystack).toLocaleLowerCase("en-US");
const expected = words(subject);
return (
expected.length > 0 &&
expected.filter((word) => source.includes(word)).length >= expected.length
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

containsSubject returns false for every subject with only short words.

words() keeps only tokens of length 4 or more. For a subject such as "NPR", "Vox", "BBC", or an author named "Li Wu", expected is empty, and the guard expected.length > 0 makes containsSubject return false.

This propagates to Lines 317-324. A journalist-context or publication-context mission with such a subject is always rejected with "The candidate does not identify the requested context subject", even when the candidate names the subject verbatim. The same failure applies at Lines 303-316 for the context subject check.

Add a fallback that matches the normalized full subject when no long tokens exist.

🐛 Proposed fix for short subjects
 function containsSubject(haystack: string, subject: string): boolean {
   const source = normalizeEvidenceText(haystack).toLocaleLowerCase("en-US");
+  const normalizedSubject = normalizeEvidenceText(subject).toLocaleLowerCase("en-US").trim();
+  if (!normalizedSubject) return false;
   const expected = words(subject);
+  if (expected.length === 0) return source.includes(normalizedSubject);
   return (
     expected.length > 0 &&
     expected.filter((word) => source.includes(word)).length >= expected.length
   );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function containsSubject(haystack: string, subject: string): boolean {
const source = normalizeEvidenceText(haystack).toLocaleLowerCase("en-US");
const expected = words(subject);
return (
expected.length > 0 &&
expected.filter((word) => source.includes(word)).length >= expected.length
);
}
function containsSubject(haystack: string, subject: string): boolean {
const source = normalizeEvidenceText(haystack).toLocaleLowerCase("en-US");
const normalizedSubject = normalizeEvidenceText(subject).toLocaleLowerCase("en-US").trim();
if (!normalizedSubject) return false;
const expected = words(subject);
if (expected.length === 0) return source.includes(normalizedSubject);
return (
expected.length > 0 &&
expected.filter((word) => source.includes(word)).length >= expected.length
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/evidence/validation.ts` around lines 38 - 45,
Update containsSubject to fall back to matching the normalized full subject when
words(subject) produces no long tokens, while preserving the existing
token-based matching for subjects with eligible words. Ensure short subjects
such as “NPR”, “Vox”, “BBC”, and “Li Wu” are recognized when present verbatim.

Comment on lines +259 to +288
if (decision.context && !expectedKind)
return {
...result(
false,
"verified-url",
basic.validation.excerptMatches,
"Political context signals are restricted to context missions.",
),
canonicalUrl: basic.canonicalUrl,
};
if (expectedKind && !decision.context)
return {
...result(
false,
"verified-url",
basic.validation.excerptMatches,
"Context missions require a source-backed context signal.",
),
canonicalUrl: basic.canonicalUrl,
};
if (decision.context && decision.context.sourceKind !== expectedKind && expectedKind)
return {
...result(
false,
"verified-url",
basic.validation.excerptMatches,
"The context signal does not match the mission purpose.",
),
canonicalUrl: basic.canonicalUrl,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

A missing-background mission cannot produce evidence without a political context signal.

expectedContextKind returns "topic-context" for missing-background. Line 269 then rejects every decision from such a mission when decision.context is null.

This conflicts with the adjudicator instruction in packages/intelligence/src/evidence/adjudication.ts Line 74: the model must emit context only when it is explicitly present in the candidate. A missing-background candidate that provides plain factual background, with no political lean, produces a decision without context. Validation drops it.

The result inverts the intent of the change. The model must fabricate a score for the decision to survive, or all background evidence is lost.

Require a context signal only for the identity-bound kinds, and allow topic-context and comparable-coverage decisions without one.

🐛 Proposed fix
-  if (expectedKind && !decision.context)
+  if (
+    expectedKind &&
+    ["journalist-work", "publication-history"].includes(expectedKind) &&
+    !decision.context
+  )
     return {
       ...result(
         false,
         "verified-url",
         basic.validation.excerptMatches,
         "Context missions require a source-backed context signal.",
       ),
       canonicalUrl: basic.canonicalUrl,
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (decision.context && !expectedKind)
return {
...result(
false,
"verified-url",
basic.validation.excerptMatches,
"Political context signals are restricted to context missions.",
),
canonicalUrl: basic.canonicalUrl,
};
if (expectedKind && !decision.context)
return {
...result(
false,
"verified-url",
basic.validation.excerptMatches,
"Context missions require a source-backed context signal.",
),
canonicalUrl: basic.canonicalUrl,
};
if (decision.context && decision.context.sourceKind !== expectedKind && expectedKind)
return {
...result(
false,
"verified-url",
basic.validation.excerptMatches,
"The context signal does not match the mission purpose.",
),
canonicalUrl: basic.canonicalUrl,
};
if (decision.context && !expectedKind)
return {
...result(
false,
"verified-url",
basic.validation.excerptMatches,
"Political context signals are restricted to context missions.",
),
canonicalUrl: basic.canonicalUrl,
};
if (
expectedKind &&
["journalist-work", "publication-history"].includes(expectedKind) &&
!decision.context
)
return {
...result(
false,
"verified-url",
basic.validation.excerptMatches,
"Context missions require a source-backed context signal.",
),
canonicalUrl: basic.canonicalUrl,
};
if (decision.context && decision.context.sourceKind !== expectedKind && expectedKind)
return {
...result(
false,
"verified-url",
basic.validation.excerptMatches,
"The context signal does not match the mission purpose.",
),
canonicalUrl: basic.canonicalUrl,
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/evidence/validation.ts` around lines 259 - 288,
Update the validation checks around expectedKind and decision.context so a
missing context signal is required only for identity-bound context kinds.
Preserve rejection for identity-bound missions lacking decision.context, but
allow topic-context and comparable-coverage decisions to proceed without one;
keep existing sourceKind mismatch validation for supplied context signals.

Comment on lines 84 to +87
const missions = options.plan.missions.filter(
(mission) =>
mission.canServeSections.some((section) => requested.has(section)) &&
!coveredMissionIds.has(mission.id),
options.ledger.isMissionFailed(mission.id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

A mission whose candidates are all rejected can never be retried.

The targeted retry filter now requires options.ledger.isMissionFailed(mission.id). failedMissions is set only in EvidenceLedger.accept when batch.status === "failed". Adjudication rejections in acceptAdjudications do not mark a mission failed.

Consider this sequence:

  1. A provider returns candidates for mission m1 and reports status: "completed". m1 enters completedMissions.
  2. Every candidate for m1 is rejected by validateEvidenceAdjudication (unverifiable URL, excerpt mismatch, claim not relevant).
  3. The section served by m1 renders empty.
  4. failedSectionsForReport in packages/intelligence/src/pipeline.ts reports no failure, so the report status is complete.
  5. If the reader requests a retry for that section, this filter excludes m1, so the retry performs no retrieval.

The section is permanently empty with no recovery path and no signal to the reader. The previous snapshot-based check covered this case because it inspected whether the mission produced assertions.

Distinguishing completed-empty from failed is correct for a provider that honestly returned nothing. A mission whose every candidate was rejected during adjudication is a different state. Track it, for example by marking a mission as failed or as exhausted-rejected when it contributed candidates but produced zero accepted assertions.

#!/bin/bash
# Description: Confirm failedMissions is only written on batch failure and never on adjudication rejection.
set -euo pipefail

echo "== writes to failedMissions =="
rg -n -C 4 'failedMissions' packages/intelligence/src

echo "== acceptAdjudications rejection paths =="
ast-grep run --lang typescript --pattern 'acceptAdjudications($$$) { $$$ }' packages/intelligence/src/evidence/source-ledger.ts

echo "== retry eligibility filter =="
rg -n -C 10 'isMissionFailed' packages/intelligence/src
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/retrieval/coordinator.ts` around lines 84 - 87,
Update the mission outcome tracking used by the retrieval coordinator so a
mission that contributes candidates but has all of them rejected during
acceptAdjudications is marked retry-eligible, such as failed or
exhausted-rejected. Preserve the distinction for completed missions that
honestly return no candidates, and ensure the filter around
options.ledger.isMissionFailed(mission.id) includes adjudication-exhausted
missions when retrying the affected section.

Comment on lines +44 to +51
while (inFlight.size > 0) {
if (controller.signal.aborted) throw abortError(controller.signal);
const result = await Promise.race(inFlight.values());
inFlight.delete(result.id);
if (result.error !== undefined) throw result.error;
if (result.value !== undefined) yield result.value;
while (inFlight.size < concurrency && cursor < queue.length) start();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

undefined is used as a sentinel for both the value and the error, so results and failures can be dropped.

Two cases fail:

  • Line 49: a task that resolves to undefined is not yielded. runPriorityTasks then returns fewer results than tasks. The previous Promise.all implementation preserved every slot, including undefined, so this is a silent behavior change for any SchedulerTask<T> where T admits undefined or void.
  • Line 48: a task that rejects with undefined produces { id, error: undefined }, which is indistinguishable from success. The rejection is swallowed and the loop continues.

Use an explicit discriminant instead of undefined checks.

🐛 Proposed fix
-  const inFlight = new Map<number, Promise<{ id: number; value?: T; error?: unknown }>>();
+  type Settled<V> = { id: number; ok: true; value: V } | { id: number; ok: false; error: unknown };
+  const inFlight = new Map<number, Promise<Settled<T>>>();
   let taskId = 0;
   const start = () => {
     const task = queue[cursor++];
     if (!task) return;
     const id = taskId++;
     const promise = task
       .run()
-      .then((value) => ({ id, value }))
-      .catch((error) => ({ id, error }));
+      .then((value): Settled<T> => ({ id, ok: true, value }))
+      .catch((error): Settled<T> => ({ id, ok: false, error }));
     inFlight.set(id, promise);
   };
@@
       const result = await Promise.race(inFlight.values());
       inFlight.delete(result.id);
-      if (result.error !== undefined) throw result.error;
-      if (result.value !== undefined) yield result.value;
+      if (!result.ok) throw result.error;
+      yield result.value;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/intelligence/src/retrieval/scheduler.ts` around lines 44 - 51,
Update the result handling in the scheduler loop around Promise.race and the
inFlight task wrapper to use an explicit success/error discriminant rather than
checking whether result.value or result.error is undefined. Always yield
successful values, including undefined, and propagate rejected errors, including
undefined, while preserving task ordering and concurrency behavior.

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.

2 participants