diff --git a/AGENTS.md b/AGENTS.md
index c2092d9..c0198f1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -169,7 +169,7 @@ When contributing to this project, you must adhere strictly to these rules:
## 🚀 Next Steps & Outstanding Work
- The active remaining roadmap is `docs/canonical_data_and_analytics_plan.md`. Phases 1 and
2 are implemented and the scraper identity resolver in Phase 3 is complete. Applied migrations
- `0022` through `0037` establish deterministic identity, atomic source-profile writes,
+ `0022` through `0038` establish deterministic identity, atomic source-profile writes,
provenance, person office terms, and private normalized House and Senate roll-call facts.
Both official vote paths remain bounded, identifier-only, and
isolated from legacy `voting_records`; the versioned `0031` read RPC combines presentation-safe
@@ -182,7 +182,9 @@ When contributing to this project, you must adhere strictly to these rules:
JSON, legacy vote writes, or a public read path. `0036` records the successful manual canary
and private audit before the reviewed workflow enabled that same bounded path for schedules.
`0037` adds a narrow measure-aware public voting-record RPC without opening those private
- tables or changing the v2 vote contract; it is applied and live-validated.
+ tables or changing the v2 vote contract. `0038` adds bounded official participation summaries,
+ canonical same-scope alignment rankings, and inspectable pairwise evidence while keeping
+ state/historical legacy votes separate; both migrations are applied and live-validated.
- Monitor and resolve quarantined identity candidates instead of weakening the pre-write
boundary. A person can have federal, state, and local roles over time; those roles must
not become separate canonical people or be flattened into one office field.
@@ -207,7 +209,10 @@ When contributing to this project, you must adhere strictly to these rules:
run confirmed that the legacy person-filtered GovTrack crawl had zero attempts or failures while
the official House, Senate, and Congress.gov paths stayed healthy. Migration `0037` is now
applied: v3/v2 base-row equivalence, all 43 exact measure links across 40 roll calls, browser
- ACLs, pagination, and representative House/Senate profiles were validated live. The scraper
+ ACLs, pagination, and representative House/Senate profiles were validated live. Migration
+ `0038` is also applied: anonymous-role House/Senate summary, alignment, and pair-evidence
+ reconciliations passed with zero ACL or contract violations, and scraper preflight now requires
+ the complete analytics surface. The scraper
cron is intentionally paused during active development. Use only the manual workflow, keep the
state LittleSis fields blank and GovTrack profile mode disabled for routine runs, explicitly
enable the reviewed House/Senate/Congress.gov modes when refreshing those facts, and never
diff --git a/README.md b/README.md
index 9b3e727..3839f0b 100644
--- a/README.md
+++ b/README.md
@@ -213,6 +213,13 @@ then decorates only official rows with at most 100 exact linked measures. It exp
titles, purposes, official Congress.gov links, and bounded source freshness without granting
browser roles direct access to private provenance or legislative tables.
+Migration `0038` builds the first canonical analytics surface from those same reviewed official
+facts. The live profile summarizes coverage and participation by chamber and Congress, ranks
+same-scope peers using only shared Yea/Nay votes with a ten-vote minimum, and provides paginated
+pairwise evidence behind every comparison. State and historical legacy votes remain visibly
+separate, and every federal comparison links back to its official roll call and any exact
+Congress.gov measure metadata without opening the private fact tables.
+
Migration `0030_senate_roll_call_production_enablement.sql` verifies migration `0029`'s exact
public barrier, owner-only helper, constraint, ACL, dependency, identity-index, zero-fact, and
service-role read-only contracts. It replaces the same public function OID with a thin guarded
@@ -394,7 +401,7 @@ only runs the ETL and `nextjs.yml` only builds.
prevents half-applied data changes. Migration `0027` is the explicit exception: use
`ON_ERROR_STOP=1` but omit `--single-transaction` so its committed cutover barrier can become
visible before its second transaction drains old callers and atomically enables both gates.
- Migrations `0028` through `0037` use the normal single-transaction rule. `0028` is the private
+ Migrations `0028` through `0038` use the normal single-transaction rule. `0028` is the private
Senate source-review decision. `0029` installs the write-disabled Senate provenance
contract and hard preflight-only barrier. `0030` verifies that exact disabled state, installs
the guarded wrapper, and enables the database gates. `0031` installs the narrow public,
@@ -412,7 +419,9 @@ only runs the ETL and `nextjs.yml` only builds.
bounded writer. The current cron pause leaves runtime and manual-input defaults disabled, and
unknown events fail closed. `0037` creates the
measure-aware `get_canonical_voting_records_v3` read RPC without changing v2, legacy votes, or
- scraper writers; it advances preflight so the new read contract cannot silently drift.
+ scraper writers. `0038` adds bounded official-vote summary, same-scope alignment, and
+ inspectable pair-comparison RPCs; it keeps legacy votes separate and advances preflight so the
+ complete analytics contract cannot silently drift.
Do **not** replay the full historical migration directory against an upgraded database.
Some migrations contain guarded data decisions and review-state transitions, not just
diff --git a/docs/canonical_data_and_analytics_plan.md b/docs/canonical_data_and_analytics_plan.md
index 8c8aef7..0f5c8d7 100644
--- a/docs/canonical_data_and_analytics_plan.md
+++ b/docs/canonical_data_and_analytics_plan.md
@@ -511,11 +511,19 @@ or identity-candidate queue.
Build analytics only after identity-aware reads exist.
+The first complete capability is implemented by
+`0038_canonical_federal_voting_analytics.sql` and the profile/Connections clients. It summarizes
+official participation by chamber and Congress, ranks peers only within that exact scope using
+shared Yea/Nay casts and a ten-vote minimum, and exposes paginated roll-call evidence behind each
+pair. Present and Not Voting remain visible in participation but do not enter the alignment
+denominator. The federal lane never reads legacy `voting_records`; state and historical
+co-voting remains a clearly labeled compatibility lane until it has its own canonical model.
+
Candidate RPCs/views:
- shared donor overlap by person
- donor bridge view across canonical people
-- voting agreement/opposition by person and jurisdiction
+- state/local voting agreement by person and jurisdiction (federal official alignment is complete)
- two-hop organization/person relationship overlap
- same-state, same-chamber, and same-office cohorts
- media co-mention discovery signals
@@ -712,6 +720,18 @@ access, pagination, and representative House and Senate profiles. Scraper prefli
the 0037 marker, so deployment drift cannot remain silent. This slice added no source request,
credential, writer, or legacy vote mutation.
+The federal voting analytics slice added and applied
+`0038_canonical_federal_voting_analytics.sql` and the live Voting & Analytics/Connections
+integration. Three bounded RPCs provide per-scope participation, canonical same-chamber and
+same-Congress rankings, and inspectable pairwise official roll calls with exact measure metadata.
+The metric uses only shared Yea/Nay votes, requires at least ten, describes recorded alignment
+rather than causation, and keeps state/historical legacy co-voting in a separate labeled lane.
+The internal read model and all private provenance/fact tables remain closed to browser roles;
+the scraper write contract and source request budget are unchanged. Live anonymous-role checks
+reconciled summary categories, rates, ranking samples, and pair evidence for representative House
+and Senate profiles with zero contract or ACL failures. Scraper preflight now requires the 0038
+marker and all three RPCs so this read surface cannot silently drift.
+
During active site development the cron trigger is intentionally paused. The ETL is dispatched
manually, remains serialized, and keeps every write-mode input disabled by default. Routine full
refreshes explicitly enable only the reviewed House, Senate, and Congress.gov paths, keep the
diff --git a/frontend/src/app/[politician_id]/ConnectionsTab.tsx b/frontend/src/app/[politician_id]/ConnectionsTab.tsx
index 977c36f..d627f74 100644
--- a/frontend/src/app/[politician_id]/ConnectionsTab.tsx
+++ b/frontend/src/app/[politician_id]/ConnectionsTab.tsx
@@ -9,7 +9,8 @@ import {
type CoVoteConnection,
} from '@/lib/connections';
import { isUuid } from '@/lib/ids';
-import { profilePath } from '@/lib/routes';
+import { profilePath, votingComparisonPath } from '@/lib/routes';
+import { formatDate } from './ProfileSpokeStates';
// A connection rendered as a node in the hub-and-spoke mini-graph.
interface GraphNode {
@@ -34,19 +35,26 @@ function firstName(name: string): string {
/** Pick the strongest connections across all types for the at-a-glance graph. */
function buildGraphNodes(data: ConnectionsBundle): GraphNode[] {
+ const officialVoting = Boolean(data.federalVotingSummary);
+ const alignedVotes = officialVoting
+ ? [...data.coVotes]
+ .filter((connection) => (connection.aligned_rank ?? Number.POSITIVE_INFINITY) <= 3)
+ .sort((a, b) => (a.aligned_rank ?? 99) - (b.aligned_rank ?? 99))
+ : data.coVotes.filter((connection) => connection.agreement_rate >= 0.5).slice(0, 3);
+ const differingVotes = officialVoting
+ ? [...data.coVotes]
+ .filter((connection) => connection.disagree_count > 0 && (connection.differing_rank ?? Number.POSITIVE_INFINITY) <= 2)
+ .sort((a, b) => (a.differing_rank ?? 99) - (b.differing_rank ?? 99))
+ : data.coVotes.filter((connection) => connection.agreement_rate < 0.5).slice(0, 2);
const donors: GraphNode[] = data.sharedDonors.slice(0, 3).map((d) => ({
id: d.politician_id,
label: firstName(d.full_name),
weight: d.shared_donor_count,
kind: 'donor',
}));
- const allies: GraphNode[] = data.coVotes
- .filter((c) => c.agreement_rate >= 0.5)
- .slice(0, 3)
+ const allies: GraphNode[] = alignedVotes
.map((c) => ({ id: c.politician_id, label: firstName(c.full_name), weight: c.agree_count, kind: 'ally' }));
- const opponents: GraphNode[] = data.coVotes
- .filter((c) => c.agreement_rate < 0.5)
- .slice(0, 2)
+ const opponents: GraphNode[] = differingVotes
.map((c) => ({ id: c.politician_id, label: firstName(c.full_name), weight: c.disagree_count, kind: 'opponent' }));
const ties: GraphNode[] = data.networkTies.slice(0, 2).map((t) => ({
id: t.related_politician_id,
@@ -120,13 +128,35 @@ function PersonCardLink({ id, children }: { id: string | null; children: React.R
return
{children}
;
}
-function CoVoteCard({ c }: { c: CoVoteConnection }) {
- const ally = c.agreement_rate >= 0.5;
- return (
-
+function CoVoteCard({
+ c,
+ currentPoliticianId,
+ rankKind,
+}: {
+ c: CoVoteConnection;
+ currentPoliticianId: string;
+ rankKind?: 'aligned' | 'differing';
+}) {
+ const official = c.record_origin === 'official';
+ const ally = official
+ ? rankKind !== 'differing'
+ : c.agreement_rate >= 0.5;
+ const content = (
+ <>
-
{c.full_name}
+ {official && rankKind && (
+
+ #{rankKind === 'aligned' ? c.aligned_rank : c.differing_rank}{' '}
+ {rankKind === 'aligned' ? 'most aligned' : 'most often differed'}
+
+ )}
+
+ {c.full_name}
+
{c.current_office}
- {Math.round(c.agreement_rate * 100)}% agree
+ {Math.round(c.agreement_rate * 100)}% {official ? 'aligned' : 'agree'}
- {c.agree_count} together · {c.disagree_count} opposed · {c.shared_total} shared roll calls
+ {c.agree_count} same · {c.disagree_count} different · {c.shared_total} shared {official ? 'Yea/Nay votes' : 'roll calls'}
-
+ {official && c.chamber && c.congress && (
+
+ {c.chamber === 'house' ? 'U.S. House' : 'U.S. Senate'} · Congress {c.congress}
+ {c.first_shared_vote_date && c.last_shared_vote_date
+ ? ` · ${formatDate(c.first_shared_vote_date)}–${formatDate(c.last_shared_vote_date)}`
+ : ''}
+
+ )}
+ >
);
+
+ if (official) {
+ return (
+
+ {content}
+
+
+ Inspect shared votes
+
+
+ Official · {c.source_name || 'Federal roll call'}
+
+
+
+ );
+ }
+
+ return {content} ;
}
function SectionHeading({ children }: { children: React.ReactNode }) {
@@ -178,7 +237,13 @@ function PartialConnectionsNotice({
);
}
-const EMPTY_BUNDLE: ConnectionsBundle = { sharedDonors: [], coVotes: [], networkTies: [], failures: [] };
+const EMPTY_BUNDLE: ConnectionsBundle = {
+ sharedDonors: [],
+ coVotes: [],
+ federalVotingSummary: null,
+ networkTies: [],
+ failures: [],
+};
export default function ConnectionsTab({ politicianId, politicianName }: { politicianId: string; politicianName: string }) {
// Mock/non-UUID profiles have no DB row, so the RPCs would error — start them in a
@@ -239,18 +304,34 @@ export default function ConnectionsTab({ politicianId, politicianName }: { polit
// non-null assertion and is a safe no-op render if it somehow isn't.
if (!data) return null;
const bundle = data;
- const allies = bundle.coVotes.filter((c) => c.agreement_rate >= 0.5).sort((a, b) => b.agree_count - a.agree_count);
- const opponents = bundle.coVotes.filter((c) => c.agreement_rate < 0.5).sort((a, b) => b.disagree_count - a.disagree_count);
+ const officialVoting = Boolean(bundle.federalVotingSummary);
+ const allies = officialVoting
+ ? [...bundle.coVotes]
+ .filter((connection) => (connection.aligned_rank ?? Number.POSITIVE_INFINITY) <= 6)
+ .sort((a, b) => (a.aligned_rank ?? 99) - (b.aligned_rank ?? 99))
+ : bundle.coVotes
+ .filter((connection) => connection.agreement_rate >= 0.5)
+ .sort((a, b) => b.agree_count - a.agree_count);
+ const opponents = officialVoting
+ ? [...bundle.coVotes]
+ .filter((connection) => connection.disagree_count > 0 && (connection.differing_rank ?? Number.POSITIVE_INFINITY) <= 6)
+ .sort((a, b) => (a.differing_rank ?? 99) - (b.differing_rank ?? 99))
+ : bundle.coVotes
+ .filter((connection) => connection.agreement_rate < 0.5)
+ .sort((a, b) => b.disagree_count - a.disagree_count);
const graphNodes = buildGraphNodes(bundle);
- const isEmpty = !bundle.sharedDonors.length && !bundle.coVotes.length && !bundle.networkTies.length;
+ const isEmpty = !bundle.sharedDonors.length
+ && !bundle.coVotes.length
+ && !bundle.federalVotingSummary
+ && !bundle.networkTies.length;
if (isEmpty) {
return (
- No cross-referenced connections found yet. Co-voting connections appear once roll-call data has been
- re-ingested with stable roll-call ids; shared-donor links require overlapping FEC donors.
+ No cross-referenced connections found yet. Voting comparisons require shared roll calls;
+ shared-donor links require overlapping FEC donors.
);
@@ -266,8 +347,8 @@ export default function ConnectionsTab({ politicianId, politicianName }: { polit
Connection Map
- Shared donors / voting allies
- Opponents / network ties
+ Shared donors / voting alignment
+ Voting differences / network ties
)}
@@ -293,14 +374,41 @@ export default function ConnectionsTab({ politicianId, politicianName }: { polit
)}
- {/* Co-voting (verified) */}
- {(allies.length > 0 || opponents.length > 0) && (
+ {/* Official federal alignment, or a clearly separate legacy state/historical fallback. */}
+ {(bundle.federalVotingSummary || allies.length > 0 || opponents.length > 0) && (
- Voting Allies & Opponents
-
- {allies.slice(0, 6).map((c) => )}
- {opponents.slice(0, 6).map((c) => )}
-
+
+ {officialVoting ? 'Official Federal Voting Alignment' : 'State & Historical Co-voting'}
+
+
+ {officialVoting
+ ? `Exact shared Yea/Nay votes from ${bundle.federalVotingSummary?.chamber === 'house' ? 'the U.S. House' : 'the U.S. Senate'}, Congress ${bundle.federalVotingSummary?.congress}. Present and Not Voting are excluded from alignment.`
+ : 'This compatibility view uses retained state and historical vote feeds. It is not blended into the official federal alignment metric.'}
+
+ {allies.length === 0 && opponents.length === 0 ? (
+
+ No peers meet the minimum shared-vote sample in the current official window.
+
+ ) : (
+
+ {allies.slice(0, 6).map((c) => (
+
+ ))}
+ {opponents.slice(0, 6).map((c) => (
+
+ ))}
+
+ )}
)}
diff --git a/frontend/src/app/[politician_id]/PoliticianClient.tsx b/frontend/src/app/[politician_id]/PoliticianClient.tsx
index 178c2c6..bfbf705 100644
--- a/frontend/src/app/[politician_id]/PoliticianClient.tsx
+++ b/frontend/src/app/[politician_id]/PoliticianClient.tsx
@@ -21,7 +21,7 @@ export interface PoliticianData extends ProfileHeader {
const TABS: { id: TabId; label: string }[] = [
{ id: 'financial', label: 'Financial Disclosures' },
{ id: 'donors', label: 'Campaign Donors' },
- { id: 'votes', label: 'Voting Record' },
+ { id: 'votes', label: 'Voting & Analytics' },
{ id: 'connections', label: 'Connections' },
{ id: 'media', label: 'Media' },
];
@@ -113,7 +113,9 @@ export default function PoliticianClient({ politician }: { politician: Politicia
{activeTab === 'financial' &&
}
{activeTab === 'donors' &&
}
- {activeTab === 'votes' &&
}
+ {activeTab === 'votes' && (
+
+ )}
{activeTab === 'connections' && (
)}
diff --git a/frontend/src/app/[politician_id]/VotingAnalyticsPanel.tsx b/frontend/src/app/[politician_id]/VotingAnalyticsPanel.tsx
new file mode 100644
index 0000000..eee5fdb
--- /dev/null
+++ b/frontend/src/app/[politician_id]/VotingAnalyticsPanel.tsx
@@ -0,0 +1,678 @@
+"use client";
+
+import Link from 'next/link';
+import { useCallback, useEffect, useState } from 'react';
+import { DEFAULT_PROFILE_PAGE_SIZE, emptyPage, type PageResult } from '@/lib/pagination';
+import { profilePath } from '@/lib/routes';
+import {
+ fetchFederalVotingAlignment,
+ fetchFederalVotingComparison,
+ fetchFederalVotingSummaries,
+ type FederalVotingAlignment,
+ type FederalVotingComparison,
+ type FederalVotingComparisonFilter,
+ type FederalVotingSummary,
+} from '@/lib/votingAnalytics';
+import type { LegislativeMeasure, LegislativeMeasureType } from '@/lib/votingRecords';
+import { formatDate, PaginationControls } from './ProfileSpokeStates';
+
+const MEASURE_TYPE_LABELS: Record
= {
+ hr: 'H.R.',
+ s: 'S.',
+ hjres: 'H.J.Res.',
+ sjres: 'S.J.Res.',
+ hconres: 'H.Con.Res.',
+ sconres: 'S.Con.Res.',
+ hres: 'H.Res.',
+ sres: 'S.Res.',
+ hamdt: 'H.Amdt.',
+ samdt: 'S.Amdt.',
+ suamdt: 'Senate unprinted amendment',
+};
+
+function scopeKey(summary: Pick): string {
+ return `${summary.chamber}:${summary.congress}`;
+}
+
+function chamberLabel(chamber: FederalVotingSummary['chamber']): string {
+ return chamber === 'house' ? 'U.S. House' : 'U.S. Senate';
+}
+
+function initialComparisonPeer(): string | null {
+ if (typeof window === 'undefined') return null;
+ const candidate = new URLSearchParams(window.location.search).get('compare');
+ return candidate && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(candidate)
+ ? candidate
+ : null;
+}
+
+function updateComparisonUrl(peerId: string | null) {
+ if (typeof window === 'undefined') return;
+ const url = new URL(window.location.href);
+ if (peerId) url.searchParams.set('compare', peerId);
+ else url.searchParams.delete('compare');
+ window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
+}
+
+function percentage(value: number): string {
+ return `${Math.round(value * 100)}%`;
+}
+
+function measureLabel(measure: LegislativeMeasure): string {
+ return `${MEASURE_TYPE_LABELS[measure.measure_type]} ${measure.measure_number}`;
+}
+
+function SummaryCard({ label, value, detail }: { label: string; value: string; detail: string }) {
+ return (
+
+
+ {label}
+
+
{value}
+
{detail}
+
+ );
+}
+
+function PeerCard({
+ peer,
+ rankKind,
+ selected,
+ onCompare,
+}: {
+ peer: FederalVotingAlignment;
+ rankKind: 'aligned' | 'differing';
+ selected: boolean;
+ onCompare: () => void;
+}) {
+ const rank = rankKind === 'aligned' ? peer.aligned_rank : peer.differing_rank;
+ const count = rankKind === 'aligned' ? peer.agree_count : peer.differ_count;
+
+ return (
+
+
+
+
+ #{rank} {rankKind === 'aligned' ? 'most aligned' : 'most often differed'}
+
+
{peer.full_name}
+
+ {[peer.current_office, peer.party].filter(Boolean).join(' · ') || 'Office details unavailable'}
+
+
+
+ {percentage(peer.agreement_rate)} aligned
+
+
+
+ {peer.agree_count} same · {peer.differ_count} different · {peer.shared_substantive_count} shared Yea/Nay votes
+
+
+ {count} recorded {rankKind === 'aligned' ? 'agreements' : 'differences'} from {formatDate(peer.first_shared_vote_date)} to {formatDate(peer.last_shared_vote_date)}
+
+
+
+ {selected ? 'Viewing comparison' : 'Compare shared votes'}
+
+
+ Open profile
+
+
+
+ );
+}
+
+function ComparisonMeasure({ measure }: { measure: LegislativeMeasure }) {
+ return (
+
+
+
{measureLabel(measure)}
+ {measure.official_url && (
+
+ Congress.gov
+
+ )}
+
+ {measure.title &&
{measure.title}
}
+ {measure.purpose && measure.purpose !== measure.title && (
+
Purpose: {measure.purpose}
+ )}
+
+ );
+}
+
+function ComparisonRecordCard({
+ record,
+ peerName,
+ politicianName,
+}: {
+ record: FederalVotingComparison;
+ peerName: string;
+ politicianName: string;
+}) {
+ const agrees = record.comparison === 'agree';
+
+ return (
+
+
+
+
+
+ {agrees ? 'Agreement' : 'Different votes'}
+
+ {formatDate(record.vote_date)}
+ Roll call {record.roll_call_number}
+ Session {record.session}
+
+
{record.question}
+ {record.vote_result && (
+
Result: {record.vote_result}
+ )}
+
+
+
+
+ {politicianName}
+
+ {record.person_vote_cast}
+
+
+ {peerName}
+ {record.peer_vote_cast}
+
+
+
+ {record.measures.length > 0 && (
+
+ {record.measures.map((measure) => (
+
+ ))}
+
+ )}
+
+
{chamberLabel(record.chamber)} · Congress {record.congress}
+ {record.source_url ? (
+
+ Source: {record.source_name}
+
+ ) : (
+
Source: {record.source_name}
+ )}
+
+
+ );
+}
+
+function ComparisonPanel({
+ politicianId,
+ politicianName,
+ peer,
+ summary,
+ onClose,
+}: {
+ politicianId: string;
+ politicianName: string;
+ peer: FederalVotingAlignment;
+ summary: FederalVotingSummary;
+ onClose: () => void;
+}) {
+ const peerId = peer.peer_person_id;
+ const peerName = peer.full_name;
+ const [filter, setFilter] = useState('');
+ const [page, setPage] = useState(0);
+ const [result, setResult] = useState | null>(null);
+ const [loading, setLoading] = useState(Boolean(peerId));
+ const [error, setError] = useState(false);
+ const [retryKey, setRetryKey] = useState(0);
+
+ useEffect(() => {
+ if (!peerId) return;
+ let cancelled = false;
+ fetchFederalVotingComparison(
+ politicianId,
+ peerId,
+ summary.chamber,
+ summary.congress,
+ page,
+ DEFAULT_PROFILE_PAGE_SIZE,
+ filter || undefined,
+ )
+ .then((nextResult) => {
+ if (!cancelled) {
+ setResult(nextResult);
+ setError(false);
+ }
+ })
+ .catch((e) => {
+ if (!cancelled) {
+ console.error('Failed to load federal voting comparison:', e);
+ setError(true);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [filter, page, peerId, politicianId, retryKey, summary.chamber, summary.congress]);
+
+ const changeFilter = (next: FederalVotingComparisonFilter | '') => {
+ setFilter(next);
+ setPage(0);
+ setResult(null);
+ setLoading(true);
+ setError(false);
+ };
+
+ const changePage = (nextPage: number) => {
+ setPage(nextPage);
+ setResult(null);
+ setLoading(true);
+ setError(false);
+ };
+
+ const current = result ?? emptyPage(page);
+
+ return (
+
+
+
+
Official pairwise evidence
+
Shared votes with {peerName}
+
+ Only exact shared Yea/Nay votes from {chamberLabel(summary.chamber)} in Congress {summary.congress} are compared. Present and Not Voting do not affect alignment.
+
+
+
+ Close comparison
+
+
+
+
+ {([
+ ['', 'All shared votes'],
+ ['agree', 'Agreements'],
+ ['differ', 'Differences'],
+ ] as const).map(([value, label]) => (
+ changeFilter(value)}
+ className={`min-h-11 rounded-full border px-4 text-sm font-bold cursor-pointer ${
+ filter === value
+ ? 'border-[var(--color-official-link)] bg-[var(--color-official-link)] text-white'
+ : 'border-[var(--color-official-border)] text-[var(--color-official-text-muted)] hover:border-[var(--color-official-link)]'
+ }`}
+ >
+ {label}
+
+ ))}
+
+
+ {loading && !result ? (
+
+ ) : error && !result ? (
+
+
Could not load the shared-vote evidence.
+
{
+ setLoading(true);
+ setError(false);
+ setRetryKey((value) => value + 1);
+ }}
+ className="mt-3 min-h-11 rounded-full border border-[var(--color-official-border)] px-4 text-sm font-bold text-[var(--color-official-link)] hover:border-[var(--color-official-link)] cursor-pointer"
+ >
+ Retry comparison
+
+
+ ) : current.rows.length === 0 ? (
+
+ No {filter === 'agree' ? 'agreements' : filter === 'differ' ? 'differences' : 'shared substantive votes'} were found in this scope.
+
+ ) : (
+
+ {current.rows.map((record) => (
+
+ ))}
+ {error && (
+
Could not refresh this comparison page.
+ )}
+
+
+ )}
+
+ );
+}
+
+export default function VotingAnalyticsPanel({
+ politicianId,
+ politicianName,
+}: {
+ politicianId: string;
+ politicianName: string;
+}) {
+ const [summaries, setSummaries] = useState(null);
+ const [selectedScopeKey, setSelectedScopeKey] = useState(null);
+ const [alignment, setAlignment] = useState(null);
+ const [selectedPeerId, setSelectedPeerId] = useState(initialComparisonPeer);
+ const [summaryError, setSummaryError] = useState(false);
+ const [alignmentError, setAlignmentError] = useState(false);
+ const [summaryRetryKey, setSummaryRetryKey] = useState(0);
+ const [alignmentRetryKey, setAlignmentRetryKey] = useState(0);
+
+ useEffect(() => {
+ let cancelled = false;
+ fetchFederalVotingSummaries(politicianId)
+ .then((nextSummaries) => {
+ if (cancelled) return;
+ setSummaries(nextSummaries);
+ setSummaryError(false);
+ setSelectedScopeKey((currentKey) => {
+ if (currentKey && nextSummaries.some((summary) => scopeKey(summary) === currentKey)) {
+ return currentKey;
+ }
+ return nextSummaries[0] ? scopeKey(nextSummaries[0]) : null;
+ });
+ })
+ .catch((e) => {
+ if (!cancelled) {
+ console.error('Failed to load federal voting summary:', e);
+ setSummaryError(true);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [politicianId, summaryRetryKey]);
+
+ const selectedSummary = summaries?.find((summary) => scopeKey(summary) === selectedScopeKey)
+ ?? summaries?.[0]
+ ?? null;
+
+ useEffect(() => {
+ if (!selectedSummary) return;
+ let cancelled = false;
+ fetchFederalVotingAlignment(
+ politicianId,
+ selectedSummary.chamber,
+ selectedSummary.congress,
+ )
+ .then((nextAlignment) => {
+ if (!cancelled) {
+ setAlignment(nextAlignment);
+ setAlignmentError(false);
+ }
+ })
+ .catch((e) => {
+ if (!cancelled) {
+ console.error('Failed to load federal voting alignment:', e);
+ setAlignmentError(true);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [alignmentRetryKey, politicianId, selectedSummary]);
+
+ const selectScope = (nextKey: string) => {
+ setSelectedScopeKey(nextKey);
+ setAlignment(null);
+ setAlignmentError(false);
+ setSelectedPeerId(null);
+ updateComparisonUrl(null);
+ };
+
+ const selectPeer = useCallback((peerId: string) => {
+ setSelectedPeerId(peerId);
+ updateComparisonUrl(peerId);
+ }, []);
+
+ if (!summaries && !summaryError) {
+ return (
+
+ );
+ }
+
+ if (summaryError && !summaries) {
+ return (
+
+ Could not load official federal voting analytics.
+ {
+ setSummaryError(false);
+ setSummaryRetryKey((value) => value + 1);
+ }}
+ className="mt-3 min-h-11 rounded-full border border-[var(--color-official-border)] px-4 text-sm font-bold text-[var(--color-official-link)] hover:border-[var(--color-official-link)] cursor-pointer"
+ >
+ Retry analytics
+
+
+ );
+ }
+
+ if (!selectedSummary) {
+ return (
+
+ Official federal voting analytics
+
+ No verified House or Senate roll-call window is available for this profile. State and historical voting records remain available below when present.
+
+
+ );
+ }
+
+ const aligned = [...(alignment ?? [])]
+ .filter((peer) => peer.aligned_rank <= 6)
+ .sort((a, b) => a.aligned_rank - b.aligned_rank);
+ const differing = [...(alignment ?? [])]
+ .filter((peer) => peer.differ_count > 0 && peer.differing_rank <= 6)
+ .sort((a, b) => a.differing_rank - b.differing_rank);
+ const selectedPeer = alignment?.find((peer) => peer.peer_person_id === selectedPeerId) ?? null;
+
+ return (
+
+
+
+
+
+
+ Official analytics
+
+
+ {selectedSummary.source_name}
+
+
+
Federal voting analytics
+
+ Coverage, participation, and peer comparisons come only from exact official roll calls tied to canonical people. These numbers describe recorded voting overlap, not political influence or endorsement.
+
+
+ {summaries && summaries.length > 1 && (
+
+ Scope
+ selectScope(event.target.value)}
+ className="mt-2 block min-h-11 rounded-full border border-[var(--color-official-border)] bg-[var(--color-official-bg-alt)] px-4 text-sm normal-case tracking-normal text-[var(--color-official-text)] focus:border-[var(--color-official-link)] focus:outline-none"
+ >
+ {summaries.map((summary) => (
+
+ {chamberLabel(summary.chamber)} · Congress {summary.congress}
+
+ ))}
+
+
+ )}
+
+
+
+ {chamberLabel(selectedSummary.chamber)} · Congress {selectedSummary.congress} · {formatDate(selectedSummary.first_vote_date)}–{formatDate(selectedSummary.last_vote_date)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Official voting alignment
+
+ Rankings require at least {selectedSummary.alignment_minimum_shared_votes} shared Yea/Nay votes in the same chamber and Congress. “Different” means opposite recorded votes on the same roll call.
+
+
+ {selectedSummary.source_updated_at && (
+
+ Observed {formatDate(selectedSummary.source_updated_at)}
+
+ )}
+
+
+ {!alignment && !alignmentError ? (
+
+ ) : alignmentError && !alignment ? (
+
+
Could not load official peer alignment.
+
{
+ setAlignmentError(false);
+ setAlignmentRetryKey((value) => value + 1);
+ }}
+ className="mt-3 min-h-11 rounded-full border border-[var(--color-official-border)] px-4 text-sm font-bold text-[var(--color-official-link)] hover:border-[var(--color-official-link)] cursor-pointer"
+ >
+ Retry alignment
+
+
+ ) : aligned.length === 0 && differing.length === 0 ? (
+
+ No peers meet the minimum shared-vote sample in this scope yet.
+
+ ) : (
+
+
+
Most aligned
+
+ {aligned.map((peer) => (
+
selectPeer(peer.peer_person_id)}
+ />
+ ))}
+
+
+
+
Most often differed
+
+ {differing.map((peer) => (
+
selectPeer(peer.peer_person_id)}
+ />
+ ))}
+
+
+
+ )}
+
+
+ {selectedPeerId && selectedPeer && (
+
{
+ setSelectedPeerId(null);
+ updateComparisonUrl(null);
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/app/[politician_id]/VotingRecordTab.tsx b/frontend/src/app/[politician_id]/VotingRecordTab.tsx
index e3ff2ff..f2dd161 100644
--- a/frontend/src/app/[politician_id]/VotingRecordTab.tsx
+++ b/frontend/src/app/[politician_id]/VotingRecordTab.tsx
@@ -9,6 +9,7 @@ import {
type VotingRecord,
} from '@/lib/votingRecords';
import { EmptyState, formatDate, LoadingBlock, LoadError, PaginationControls, SectionHeading } from './ProfileSpokeStates';
+import VotingAnalyticsPanel from './VotingAnalyticsPanel';
const VOTE_FILTERS = ['', 'Yea', 'Nay', 'Present', 'Not Voting'];
@@ -42,7 +43,22 @@ function measureLabel(measure: LegislativeMeasure): string {
return `${MEASURE_TYPE_LABELS[measure.measure_type]} ${measure.measure_number}`;
}
-export default function VotingRecordTab({ politicianId }: { politicianId: string }) {
+export default function VotingRecordTab({
+ politicianId,
+ politicianName,
+}: {
+ politicianId: string;
+ politicianName: string;
+}) {
+ return (
+
+
+
+
+ );
+}
+
+function VotingRecordsList({ politicianId }: { politicianId: string }) {
const [page, setPage] = useState(0);
const [voteCast, setVoteCast] = useState('');
const [result, setResult] = useState | null>(null);
@@ -112,9 +128,11 @@ export default function VotingRecordTab({ politicianId }: { politicianId: string
const latest = current.rows[0]?.vote_date;
return (
-
+
);
}
diff --git a/frontend/src/lib/canonicalPoliticians.ts b/frontend/src/lib/canonicalPoliticians.ts
index 1c7de9e..41e78e3 100644
--- a/frontend/src/lib/canonicalPoliticians.ts
+++ b/frontend/src/lib/canonicalPoliticians.ts
@@ -12,6 +12,9 @@ const CANONICAL_POLITICIAN_RPC_NAMES = [
'get_canonical_voting_records',
'get_canonical_voting_records_v2',
'get_canonical_voting_records_v3',
+ 'get_canonical_federal_voting_summary_v1',
+ 'get_canonical_federal_voting_alignment_v1',
+ 'get_canonical_federal_voting_comparison_v1',
'get_canonical_media_mentions',
'get_canonical_person_office_terms',
];
diff --git a/frontend/src/lib/connections.ts b/frontend/src/lib/connections.ts
index addd3df..c956a75 100644
--- a/frontend/src/lib/connections.ts
+++ b/frontend/src/lib/connections.ts
@@ -1,5 +1,10 @@
import { supabase } from './supabase';
import { safeHttpUrl } from './urls';
+import {
+ fetchLatestFederalVotingAlignment,
+ type FederalChamber,
+ type FederalVotingSummary,
+} from './votingAnalytics';
// Live cross-reference "Connections" API. These wrap the Postgres RPC functions added
// in migrations/0003_connections.sql, which compute connections on demand from the
@@ -24,6 +29,14 @@ export interface CoVoteConnection {
disagree_count: number;
shared_total: number;
agreement_rate: number; // 0..1
+ record_origin: 'official' | 'legacy';
+ chamber: FederalChamber | null;
+ congress: number | null;
+ aligned_rank: number | null;
+ differing_rank: number | null;
+ first_shared_vote_date: string | null;
+ last_shared_vote_date: string | null;
+ source_name: string | null;
}
export interface NetworkTie {
@@ -44,6 +57,7 @@ export interface ConnectionLaneFailure {
export interface ConnectionsBundle {
sharedDonors: SharedDonorConnection[];
coVotes: CoVoteConnection[];
+ federalVotingSummary: FederalVotingSummary | null;
networkTies: NetworkTie[];
failures: ConnectionLaneFailure[];
}
@@ -65,16 +79,57 @@ function normalizeDonor(d: SharedDonorConnection): SharedDonorConnection {
return { ...d, shared_donor_count: toNum(d.shared_donor_count), shared_total_amount: toNum(d.shared_total_amount) };
}
-function normalizeCoVote(c: CoVoteConnection): CoVoteConnection {
+function normalizeLegacyCoVote(c: CoVoteConnection): CoVoteConnection {
return {
...c,
agree_count: toNum(c.agree_count),
disagree_count: toNum(c.disagree_count),
shared_total: toNum(c.shared_total),
agreement_rate: toNum(c.agreement_rate),
+ record_origin: 'legacy',
+ chamber: null,
+ congress: null,
+ aligned_rank: null,
+ differing_rank: null,
+ first_shared_vote_date: null,
+ last_shared_vote_date: null,
+ source_name: null,
};
}
+async function fetchVotingConnections(politicianId: string): Promise<{
+ rows: CoVoteConnection[];
+ summary: FederalVotingSummary | null;
+}> {
+ const official = await fetchLatestFederalVotingAlignment(politicianId);
+ if (official.summary) {
+ return {
+ summary: official.summary,
+ rows: official.peers.map((peer) => ({
+ politician_id: peer.peer_person_id,
+ full_name: peer.full_name,
+ current_office: peer.current_office,
+ party: peer.party,
+ agree_count: peer.agree_count,
+ disagree_count: peer.differ_count,
+ shared_total: peer.shared_substantive_count,
+ agreement_rate: peer.agreement_rate,
+ record_origin: 'official',
+ chamber: peer.chamber,
+ congress: peer.congress,
+ aligned_rank: peer.aligned_rank,
+ differing_rank: peer.differing_rank,
+ first_shared_vote_date: peer.first_shared_vote_date,
+ last_shared_vote_date: peer.last_shared_vote_date,
+ source_name: peer.source_name,
+ })),
+ };
+ }
+
+ const legacy = await rpc
('get_covoting', politicianId);
+ return { summary: null, rows: legacy.map(normalizeLegacyCoVote) };
+}
+
function normalizeTie(tie: NetworkTie): NetworkTie {
return { ...tie, url: safeHttpUrl(tie.url) };
}
@@ -91,13 +146,13 @@ function normalizeTie(tie: NetworkTie): NetworkTie {
export async function fetchConnections(politicianId: string): Promise {
const [donors, votes, ties] = await Promise.allSettled([
rpc('get_shared_donors', politicianId),
- rpc('get_covoting', politicianId),
+ fetchVotingConnections(politicianId),
rpc('get_network_ties', politicianId),
]);
const lanes = [
['get_shared_donors', donors],
- ['get_covoting', votes],
+ ['federal voting analytics / get_covoting', votes],
['get_network_ties', ties],
] as const;
@@ -110,12 +165,13 @@ export async function fetchConnections(politicianId: string): Promise;
+
+function optionalText(value: unknown): string | null {
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ return trimmed || null;
+}
+
+function numberValue(value: unknown): number {
+ const parsed = typeof value === 'number' ? value : Number(value);
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+function integerValue(value: unknown): number {
+ return Math.trunc(numberValue(value));
+}
+
+function boundedRate(value: unknown): number {
+ return Math.min(1, Math.max(0, numberValue(value)));
+}
+
+function chamberValue(value: unknown): FederalChamber | null {
+ return value === 'house' || value === 'senate' ? value : null;
+}
+
+function normalizeSummary(row: UnknownRow): FederalVotingSummary | null {
+ const personId = optionalText(row.person_id);
+ const chamber = chamberValue(row.chamber);
+ const congress = integerValue(row.congress);
+ const firstVoteDate = optionalText(row.first_vote_date);
+ const lastVoteDate = optionalText(row.last_vote_date);
+ const sourceName = optionalText(row.source_name);
+
+ if (
+ !personId
+ || !isUuid(personId)
+ || !chamber
+ || congress <= 0
+ || !firstVoteDate
+ || !lastVoteDate
+ || !sourceName
+ ) {
+ return null;
+ }
+
+ return {
+ person_id: personId,
+ chamber,
+ congress,
+ first_vote_date: firstVoteDate,
+ last_vote_date: lastVoteDate,
+ covered_vote_count: integerValue(row.covered_vote_count),
+ participating_vote_count: integerValue(row.participating_vote_count),
+ substantive_vote_count: integerValue(row.substantive_vote_count),
+ yea_count: integerValue(row.yea_count),
+ nay_count: integerValue(row.nay_count),
+ present_count: integerValue(row.present_count),
+ not_voting_count: integerValue(row.not_voting_count),
+ participation_rate: boundedRate(row.participation_rate),
+ alignment_minimum_shared_votes: Math.max(
+ 1,
+ integerValue(row.alignment_minimum_shared_votes),
+ ),
+ source_name: sourceName,
+ source_updated_at: optionalText(row.source_updated_at),
+ };
+}
+
+function normalizeAlignment(row: UnknownRow): FederalVotingAlignment | null {
+ const peerPersonId = optionalText(row.peer_person_id);
+ const fullName = optionalText(row.full_name);
+ const chamber = chamberValue(row.chamber);
+ const congress = integerValue(row.congress);
+ const firstSharedVoteDate = optionalText(row.first_shared_vote_date);
+ const lastSharedVoteDate = optionalText(row.last_shared_vote_date);
+ const sourceName = optionalText(row.source_name);
+
+ if (
+ !peerPersonId
+ || !isUuid(peerPersonId)
+ || !fullName
+ || !chamber
+ || congress <= 0
+ || !firstSharedVoteDate
+ || !lastSharedVoteDate
+ || !sourceName
+ ) {
+ return null;
+ }
+
+ return {
+ peer_person_id: peerPersonId,
+ full_name: fullName,
+ current_office: optionalText(row.current_office),
+ party: optionalText(row.party),
+ state: optionalText(row.state),
+ district: optionalText(row.district),
+ chamber,
+ congress,
+ agree_count: integerValue(row.agree_count),
+ differ_count: integerValue(row.differ_count),
+ shared_substantive_count: integerValue(row.shared_substantive_count),
+ agreement_rate: boundedRate(row.agreement_rate),
+ first_shared_vote_date: firstSharedVoteDate,
+ last_shared_vote_date: lastSharedVoteDate,
+ aligned_rank: Math.max(1, integerValue(row.aligned_rank)),
+ differing_rank: Math.max(1, integerValue(row.differing_rank)),
+ source_name: sourceName,
+ source_updated_at: optionalText(row.source_updated_at),
+ };
+}
+
+function normalizeComparison(row: UnknownRow): FederalVotingComparison | null {
+ const rollCallId = optionalText(row.roll_call_id);
+ const chamber = chamberValue(row.chamber);
+ const congress = integerValue(row.congress);
+ const session = integerValue(row.session);
+ const rollCallNumber = integerValue(row.roll_call_number);
+ const voteDate = optionalText(row.vote_date);
+ const question = optionalText(row.question);
+ const personVoteCast = row.person_vote_cast;
+ const peerVoteCast = row.peer_vote_cast;
+ const comparison = row.comparison;
+ const sourceName = optionalText(row.source_name);
+
+ if (
+ !rollCallId
+ || !chamber
+ || congress <= 0
+ || session <= 0
+ || rollCallNumber <= 0
+ || !voteDate
+ || !question
+ || (personVoteCast !== 'Yea' && personVoteCast !== 'Nay')
+ || (peerVoteCast !== 'Yea' && peerVoteCast !== 'Nay')
+ || (comparison !== 'agree' && comparison !== 'differ')
+ || !sourceName
+ ) {
+ return null;
+ }
+
+ return {
+ roll_call_id: rollCallId,
+ chamber,
+ congress,
+ session,
+ roll_call_number: rollCallNumber,
+ vote_date: voteDate,
+ question,
+ vote_result: optionalText(row.vote_result),
+ person_vote_cast: personVoteCast,
+ peer_vote_cast: peerVoteCast,
+ comparison,
+ source_name: sourceName,
+ source_url: safeHttpUrl(optionalText(row.source_url)),
+ source_updated_at: optionalText(row.source_updated_at),
+ measures: normalizeLegislativeMeasures(row.measures),
+ };
+}
+
+export async function fetchFederalVotingSummaries(
+ politicianId: string,
+): Promise {
+ if (!isUuid(politicianId)) return [];
+
+ const { data, error } = await supabase.rpc(
+ 'get_canonical_federal_voting_summary_v1',
+ { p_id: politicianId },
+ );
+ if (error) throw error;
+
+ return ((data ?? []) as UnknownRow[]).flatMap((row) => {
+ const normalized = normalizeSummary(row);
+ return normalized ? [normalized] : [];
+ });
+}
+
+export async function fetchFederalVotingAlignment(
+ politicianId: string,
+ chamber: FederalChamber,
+ congress: number,
+ resultLimitPerSide = 6,
+): Promise {
+ if (!isUuid(politicianId) || congress <= 0) return [];
+
+ const { data, error } = await supabase.rpc(
+ 'get_canonical_federal_voting_alignment_v1',
+ {
+ p_id: politicianId,
+ scope_chamber: chamber,
+ scope_congress: congress,
+ result_limit_per_side: Math.min(
+ 12,
+ Math.max(1, Math.trunc(resultLimitPerSide)),
+ ),
+ },
+ );
+ if (error) throw error;
+
+ return ((data ?? []) as UnknownRow[]).flatMap((row) => {
+ const normalized = normalizeAlignment(row);
+ return normalized ? [normalized] : [];
+ });
+}
+
+export async function fetchLatestFederalVotingAlignment(
+ politicianId: string,
+ resultLimitPerSide = 6,
+): Promise<{
+ summary: FederalVotingSummary | null;
+ peers: FederalVotingAlignment[];
+}> {
+ const summaries = await fetchFederalVotingSummaries(politicianId);
+ const summary = summaries[0] ?? null;
+ if (!summary) return { summary: null, peers: [] };
+
+ const peers = await fetchFederalVotingAlignment(
+ politicianId,
+ summary.chamber,
+ summary.congress,
+ resultLimitPerSide,
+ );
+ return { summary, peers };
+}
+
+export async function fetchFederalVotingComparison(
+ politicianId: string,
+ peerId: string,
+ chamber: FederalChamber,
+ congress: number,
+ page = 0,
+ pageSize?: number,
+ filter?: FederalVotingComparisonFilter,
+): Promise> {
+ const range = pageRange(page, pageSize);
+ if (!isUuid(politicianId) || !isUuid(peerId) || politicianId === peerId) {
+ return { rows: [], count: 0, page: range.page, pageSize: range.pageSize };
+ }
+
+ const { data, error } = await supabase.rpc(
+ 'get_canonical_federal_voting_comparison_v1',
+ {
+ p_id: politicianId,
+ peer_id: peerId,
+ scope_chamber: chamber,
+ scope_congress: congress,
+ comparison_filter: filter ?? null,
+ result_limit: range.pageSize + 1,
+ result_offset: range.from,
+ },
+ );
+ if (error) throw error;
+
+ const normalized = ((data ?? []) as UnknownRow[]).flatMap((row) => {
+ const comparison = normalizeComparison(row);
+ return comparison ? [comparison] : [];
+ });
+
+ return {
+ rows: normalized.slice(0, range.pageSize),
+ count: null,
+ hasMore: normalized.length > range.pageSize,
+ page: range.page,
+ pageSize: range.pageSize,
+ };
+}
diff --git a/frontend/src/lib/votingRecords.ts b/frontend/src/lib/votingRecords.ts
index 808a3ac..8a9e599 100644
--- a/frontend/src/lib/votingRecords.ts
+++ b/frontend/src/lib/votingRecords.ts
@@ -249,7 +249,7 @@ function optionalText(value: unknown): string | null {
return trimmed || null;
}
-function normalizeLegislativeMeasures(value: unknown): LegislativeMeasure[] {
+export function normalizeLegislativeMeasures(value: unknown): LegislativeMeasure[] {
if (!Array.isArray(value)) return [];
const measures: LegislativeMeasure[] = [];
diff --git a/migrations/0038_canonical_federal_voting_analytics.sql b/migrations/0038_canonical_federal_voting_analytics.sql
new file mode 100644
index 0000000..17ded03
--- /dev/null
+++ b/migrations/0038_canonical_federal_voting_analytics.sql
@@ -0,0 +1,1047 @@
+-- 0038_canonical_federal_voting_analytics.sql
+--
+-- Turn the reviewed private House/Senate roll-call facts into one inspectable,
+-- canonical-person analytics feature. The internal read model remains closed to
+-- every browser and service role. Three bounded SECURITY DEFINER RPCs expose only:
+--
+-- * per-person coverage and participation summaries by chamber/Congress;
+-- * same-scope Yea/Nay alignment rankings with a ten-vote minimum sample; and
+-- * paginated pairwise Yea/Nay evidence with exact official source links and
+-- presentation-safe Congress.gov measure metadata.
+--
+-- Present and Not Voting remain visible in the participation summary but never
+-- enter the alignment denominator. Legacy voting_records are deliberately absent:
+-- state and historical coverage stays a separate presentation lane.
+
+BEGIN;
+
+SET LOCAL statement_timeout = '30s';
+
+DO $migration_preflight$
+BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM public.schema_migrations
+ WHERE migration_key = '0038_canonical_federal_voting_analytics'
+ ) THEN
+ RAISE EXCEPTION
+ 'migration 0038_canonical_federal_voting_analytics is already recorded; do not replay forward-only migrations'
+ USING ERRCODE = '55000';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1
+ FROM public.schema_migrations
+ WHERE migration_key = '0037_congress_gov_measure_read_surface'
+ AND migration_version = 37
+ ) THEN
+ RAISE EXCEPTION
+ 'migration 0037_congress_gov_measure_read_surface must be applied first'
+ USING ERRCODE = '55000';
+ END IF;
+
+ IF to_regclass(
+ 'public.canonical_verified_federal_roll_call_votes_v1'
+ ) IS NOT NULL
+ OR to_regprocedure(
+ 'public.get_canonical_federal_voting_summary_v1(uuid)'
+ ) IS NOT NULL
+ OR to_regprocedure(
+ 'public.get_canonical_federal_voting_alignment_v1(uuid,text,integer,integer)'
+ ) IS NOT NULL
+ OR to_regprocedure(
+ 'public.get_canonical_federal_voting_comparison_v1(uuid,uuid,text,integer,text,integer,integer)'
+ ) IS NOT NULL THEN
+ RAISE EXCEPTION
+ 'one or more 0038 voting analytics objects already exist without the migration marker'
+ USING ERRCODE = '55000';
+ END IF;
+
+ IF to_regprocedure(
+ 'public.get_canonical_person_legacy_ids(uuid)'
+ ) IS NULL
+ OR to_regprocedure(
+ 'public.get_canonical_politician_header(uuid)'
+ ) IS NULL THEN
+ RAISE EXCEPTION
+ 'canonical identity/header RPCs are missing'
+ USING ERRCODE = '42883';
+ END IF;
+
+ IF to_regclass('public.people') IS NULL
+ OR to_regclass('public.source_records') IS NULL
+ OR to_regclass('public.legislative_roll_calls') IS NULL
+ OR to_regclass('public.person_roll_call_votes') IS NULL
+ OR to_regclass('public.legislative_measures') IS NULL
+ OR to_regclass(
+ 'public.legislative_roll_call_measure_links'
+ ) IS NULL THEN
+ RAISE EXCEPTION
+ 'canonical identity or legislative fact tables are missing'
+ USING ERRCODE = '42P01';
+ END IF;
+
+ IF has_table_privilege('anon', 'public.source_records', 'SELECT')
+ OR has_table_privilege('authenticated', 'public.source_records', 'SELECT')
+ OR has_table_privilege(
+ 'anon',
+ 'public.legislative_roll_calls',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'authenticated',
+ 'public.legislative_roll_calls',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'anon',
+ 'public.person_roll_call_votes',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'authenticated',
+ 'public.person_roll_call_votes',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'anon',
+ 'public.legislative_measures',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'authenticated',
+ 'public.legislative_measures',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'anon',
+ 'public.legislative_roll_call_measure_links',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'authenticated',
+ 'public.legislative_roll_call_measure_links',
+ 'SELECT'
+ ) THEN
+ RAISE EXCEPTION
+ 'private legislative facts are unexpectedly readable before analytics rollout'
+ USING ERRCODE = '42501';
+ END IF;
+END
+$migration_preflight$;
+
+-- This view is an owner-only contract shared by the three public RPCs. It keeps
+-- source verification identical across summary, ranking, and evidence queries.
+-- security_invoker means an accidental future view grant still cannot bypass the
+-- underlying table ACLs; the SECURITY DEFINER RPC owner is the intended reader. Do
+-- not add security_barrier here: these predicates contain no caller-controlled
+-- functions, and indexed person/scope filters must be eligible for pushdown.
+CREATE VIEW public.canonical_verified_federal_roll_call_votes_v1
+WITH (security_invoker = true)
+AS
+SELECT
+ person_vote.source_record_id AS person_vote_source_record_id,
+ person_vote.roll_call_source_record_id,
+ person_vote.person_id,
+ person_vote.vote_cast,
+ roll_call.canonical_roll_call_key,
+ roll_call.chamber,
+ roll_call.congress,
+ roll_call.session,
+ roll_call.roll_call_number,
+ roll_call.vote_date,
+ roll_call.question,
+ roll_call.vote_result,
+ source_system.display_name AS source_name,
+ roll_call_source.source_system_key,
+ roll_call_source.source_catalog_slug,
+ roll_call_source.source_endpoint_slug,
+ roll_call_source.source_url,
+ GREATEST(
+ roll_call_source.last_seen_at,
+ person_vote_source.last_seen_at
+ ) AS source_updated_at
+FROM public.person_roll_call_votes AS person_vote
+JOIN public.people AS person
+ ON person.id = person_vote.person_id
+ AND person.status = 'active'
+JOIN public.source_records AS person_vote_source
+ ON person_vote_source.id = person_vote.source_record_id
+ AND person_vote_source.person_id = person_vote.person_id
+ AND person_vote_source.record_type = 'person_roll_call_vote'
+ AND person_vote_source.record_status = 'active'
+ AND person_vote_source.retired_at IS NULL
+ AND person_vote_source.verified_lane = 'verified'
+JOIN public.legislative_roll_calls AS roll_call
+ ON roll_call.source_record_id = person_vote.roll_call_source_record_id
+JOIN public.source_records AS roll_call_source
+ ON roll_call_source.id = roll_call.source_record_id
+ AND roll_call_source.record_type = 'legislative_roll_call'
+ AND roll_call_source.record_status = 'active'
+ AND roll_call_source.retired_at IS NULL
+ AND roll_call_source.verified_lane = 'verified'
+ AND roll_call_source.person_id IS NULL
+ AND roll_call_source.source_system_key = person_vote_source.source_system_key
+ AND roll_call_source.source_catalog_slug = person_vote_source.source_catalog_slug
+ AND roll_call_source.source_endpoint_slug = person_vote_source.source_endpoint_slug
+ AND roll_call_source.source_url = person_vote_source.source_url
+JOIN public.source_systems AS source_system
+ ON source_system.key = roll_call_source.source_system_key
+ AND source_system.source_kind = 'government'
+ AND source_system.trust_level = 'official'
+ AND source_system.verified = true
+JOIN public.source_catalog_sources AS catalog_source
+ ON catalog_source.slug = roll_call_source.source_catalog_slug
+ AND catalog_source.status = 'approved'
+ AND catalog_source.repo_fit = 'wired'
+ AND catalog_source.verified_lane = 'verified'
+JOIN public.source_catalog_endpoints AS catalog_endpoint
+ ON catalog_endpoint.source_slug = roll_call_source.source_catalog_slug
+ AND catalog_endpoint.endpoint_slug = roll_call_source.source_endpoint_slug
+ AND catalog_endpoint.status = 'approved'
+WHERE (
+ roll_call.chamber = 'house'
+ AND roll_call_source.source_system_key = 'house-clerk'
+ AND roll_call_source.source_catalog_slug = 'house-clerk-roll-call-xml'
+ AND roll_call_source.source_endpoint_slug = 'evs-roll-call-feed'
+ AND roll_call.canonical_roll_call_key ~ '^house:'
+) OR (
+ roll_call.chamber = 'senate'
+ AND roll_call_source.source_system_key = 'senate-lis'
+ AND roll_call_source.source_catalog_slug = 'senate-roll-call-xml'
+ AND roll_call_source.source_endpoint_slug = 'lis-roll-call-feed'
+ AND roll_call.canonical_roll_call_key ~ '^senate:'
+);
+
+REVOKE ALL ON TABLE public.canonical_verified_federal_roll_call_votes_v1
+ FROM PUBLIC, anon, authenticated, service_role;
+
+CREATE FUNCTION public.get_canonical_federal_voting_summary_v1(p_id uuid)
+RETURNS TABLE (
+ person_id uuid,
+ chamber text,
+ congress integer,
+ first_vote_date date,
+ last_vote_date date,
+ covered_vote_count bigint,
+ participating_vote_count bigint,
+ substantive_vote_count bigint,
+ yea_count bigint,
+ nay_count bigint,
+ present_count bigint,
+ not_voting_count bigint,
+ participation_rate numeric,
+ alignment_minimum_shared_votes integer,
+ source_name text,
+ source_updated_at timestamptz
+)
+LANGUAGE plpgsql
+STABLE
+SECURITY DEFINER
+SET search_path = ''
+AS $function$
+#variable_conflict use_column
+DECLARE
+ v_person_count integer;
+ v_person_id uuid;
+ v_person_is_active boolean;
+BEGIN
+ SELECT
+ count(DISTINCT resolved.person_id),
+ (array_agg(
+ DISTINCT resolved.person_id
+ ORDER BY resolved.person_id
+ ))[1]
+ INTO v_person_count, v_person_id
+ FROM public.get_canonical_person_legacy_ids(p_id) AS resolved;
+
+ IF v_person_count = 0 THEN
+ RETURN;
+ END IF;
+
+ IF v_person_count <> 1 THEN
+ RAISE EXCEPTION
+ 'canonical voting-summary resolution returned % people for one profile',
+ v_person_count
+ USING ERRCODE = '21000';
+ END IF;
+
+ SELECT EXISTS (
+ SELECT 1
+ FROM public.people AS person
+ WHERE person.id = v_person_id
+ AND person.status = 'active'
+ ) INTO v_person_is_active;
+
+ IF NOT v_person_is_active THEN
+ RETURN;
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ v_person_id,
+ vote.chamber,
+ vote.congress,
+ min(vote.vote_date),
+ max(vote.vote_date),
+ count(*),
+ count(*) FILTER (WHERE vote.vote_cast <> 'not_voting'),
+ count(*) FILTER (WHERE vote.vote_cast IN ('yea', 'nay')),
+ count(*) FILTER (WHERE vote.vote_cast = 'yea'),
+ count(*) FILTER (WHERE vote.vote_cast = 'nay'),
+ count(*) FILTER (WHERE vote.vote_cast = 'present'),
+ count(*) FILTER (WHERE vote.vote_cast = 'not_voting'),
+ round(
+ count(*) FILTER (
+ WHERE vote.vote_cast <> 'not_voting'
+ )::numeric / NULLIF(count(*), 0),
+ 3
+ ),
+ 10,
+ max(vote.source_name),
+ max(vote.source_updated_at)
+ FROM public.canonical_verified_federal_roll_call_votes_v1 AS vote
+ WHERE vote.person_id = v_person_id
+ GROUP BY vote.chamber, vote.congress
+ ORDER BY
+ max(vote.vote_date) DESC,
+ vote.congress DESC,
+ vote.chamber
+ LIMIT 20;
+END
+$function$;
+
+CREATE FUNCTION public.get_canonical_federal_voting_alignment_v1(
+ p_id uuid,
+ scope_chamber text DEFAULT NULL,
+ scope_congress integer DEFAULT NULL,
+ result_limit_per_side integer DEFAULT 6
+)
+RETURNS TABLE (
+ peer_person_id uuid,
+ full_name text,
+ current_office text,
+ party text,
+ state text,
+ district text,
+ chamber text,
+ congress integer,
+ agree_count bigint,
+ differ_count bigint,
+ shared_substantive_count bigint,
+ agreement_rate numeric,
+ first_shared_vote_date date,
+ last_shared_vote_date date,
+ aligned_rank bigint,
+ differing_rank bigint,
+ source_name text,
+ source_updated_at timestamptz
+)
+LANGUAGE plpgsql
+STABLE
+SECURITY DEFINER
+SET search_path = ''
+AS $function$
+#variable_conflict use_column
+DECLARE
+ v_person_count integer;
+ v_person_id uuid;
+ v_person_is_active boolean;
+ v_chamber text;
+ v_congress integer;
+ v_limit integer;
+BEGIN
+ SELECT
+ count(DISTINCT resolved.person_id),
+ (array_agg(
+ DISTINCT resolved.person_id
+ ORDER BY resolved.person_id
+ ))[1]
+ INTO v_person_count, v_person_id
+ FROM public.get_canonical_person_legacy_ids(p_id) AS resolved;
+
+ IF v_person_count = 0 THEN
+ RETURN;
+ END IF;
+
+ IF v_person_count <> 1 THEN
+ RAISE EXCEPTION
+ 'canonical voting-alignment resolution returned % people for one profile',
+ v_person_count
+ USING ERRCODE = '21000';
+ END IF;
+
+ SELECT EXISTS (
+ SELECT 1
+ FROM public.people AS person
+ WHERE person.id = v_person_id
+ AND person.status = 'active'
+ ) INTO v_person_is_active;
+
+ IF NOT v_person_is_active THEN
+ RETURN;
+ END IF;
+
+ IF (NULLIF(btrim(scope_chamber), '') IS NULL)
+ IS DISTINCT FROM (scope_congress IS NULL) THEN
+ RAISE EXCEPTION
+ 'scope_chamber and scope_congress must be supplied together'
+ USING ERRCODE = '22023';
+ END IF;
+
+ IF scope_chamber IS NULL THEN
+ SELECT vote.chamber, vote.congress
+ INTO v_chamber, v_congress
+ FROM public.canonical_verified_federal_roll_call_votes_v1 AS vote
+ WHERE vote.person_id = v_person_id
+ GROUP BY vote.chamber, vote.congress
+ ORDER BY max(vote.vote_date) DESC, vote.congress DESC, vote.chamber
+ LIMIT 1;
+ ELSE
+ v_chamber := lower(btrim(scope_chamber));
+ v_congress := scope_congress;
+ END IF;
+
+ IF v_chamber IS NULL OR v_congress IS NULL THEN
+ RETURN;
+ END IF;
+
+ IF v_chamber NOT IN ('house', 'senate') OR v_congress <= 0 THEN
+ RAISE EXCEPTION
+ 'invalid federal voting-alignment scope: chamber %, Congress %',
+ v_chamber,
+ v_congress
+ USING ERRCODE = '22023';
+ END IF;
+
+ v_limit := LEAST(
+ GREATEST(COALESCE(result_limit_per_side, 6), 1),
+ 12
+ );
+
+ RETURN QUERY
+ WITH target_votes AS MATERIALIZED (
+ SELECT
+ vote.roll_call_source_record_id,
+ vote.vote_cast,
+ vote.vote_date,
+ vote.source_name,
+ vote.source_system_key,
+ vote.source_catalog_slug,
+ vote.source_endpoint_slug,
+ vote.source_url,
+ vote.source_updated_at
+ FROM public.canonical_verified_federal_roll_call_votes_v1 AS vote
+ WHERE vote.person_id = v_person_id
+ AND vote.chamber = v_chamber
+ AND vote.congress = v_congress
+ AND vote.vote_cast IN ('yea', 'nay')
+ ),
+ scored AS (
+ SELECT
+ peer_vote.person_id AS peer_person_id,
+ count(*) FILTER (
+ WHERE peer_vote.vote_cast = target_vote.vote_cast
+ ) AS agree_count,
+ count(*) FILTER (
+ WHERE peer_vote.vote_cast <> target_vote.vote_cast
+ ) AS differ_count,
+ count(*) AS shared_substantive_count,
+ round(
+ count(*) FILTER (
+ WHERE peer_vote.vote_cast = target_vote.vote_cast
+ )::numeric / NULLIF(count(*), 0),
+ 3
+ ) AS agreement_rate,
+ min(target_vote.vote_date) AS first_shared_vote_date,
+ max(target_vote.vote_date) AS last_shared_vote_date,
+ max(target_vote.source_name) AS source_name,
+ max(GREATEST(
+ target_vote.source_updated_at,
+ peer_vote_source.last_seen_at
+ )) AS source_updated_at
+ FROM target_votes AS target_vote
+ JOIN public.person_roll_call_votes AS peer_vote
+ ON peer_vote.roll_call_source_record_id =
+ target_vote.roll_call_source_record_id
+ AND peer_vote.person_id <> v_person_id
+ AND peer_vote.vote_cast IN ('yea', 'nay')
+ JOIN public.people AS peer_person
+ ON peer_person.id = peer_vote.person_id
+ AND peer_person.status = 'active'
+ JOIN public.source_records AS peer_vote_source
+ ON peer_vote_source.id = peer_vote.source_record_id
+ AND peer_vote_source.person_id = peer_vote.person_id
+ AND peer_vote_source.record_type = 'person_roll_call_vote'
+ AND peer_vote_source.record_status = 'active'
+ AND peer_vote_source.retired_at IS NULL
+ AND peer_vote_source.verified_lane = 'verified'
+ AND peer_vote_source.source_system_key =
+ target_vote.source_system_key
+ AND peer_vote_source.source_catalog_slug =
+ target_vote.source_catalog_slug
+ AND peer_vote_source.source_endpoint_slug =
+ target_vote.source_endpoint_slug
+ AND peer_vote_source.source_url = target_vote.source_url
+ GROUP BY peer_vote.person_id
+ HAVING count(*) >= 10
+ ),
+ ranked AS (
+ SELECT
+ score.*,
+ row_number() OVER (
+ ORDER BY
+ score.agreement_rate DESC,
+ score.shared_substantive_count DESC,
+ score.peer_person_id
+ ) AS aligned_rank,
+ row_number() OVER (
+ ORDER BY
+ score.agreement_rate,
+ score.shared_substantive_count DESC,
+ score.peer_person_id
+ ) AS differing_rank
+ FROM scored AS score
+ )
+ SELECT
+ ranked.peer_person_id,
+ COALESCE(header.full_name, peer.primary_name),
+ header.current_office,
+ header.party,
+ header.state,
+ header.district,
+ v_chamber,
+ v_congress,
+ ranked.agree_count,
+ ranked.differ_count,
+ ranked.shared_substantive_count,
+ ranked.agreement_rate,
+ ranked.first_shared_vote_date,
+ ranked.last_shared_vote_date,
+ ranked.aligned_rank,
+ ranked.differing_rank,
+ ranked.source_name,
+ ranked.source_updated_at
+ FROM ranked
+ JOIN public.people AS peer
+ ON peer.id = ranked.peer_person_id
+ AND peer.status = 'active'
+ LEFT JOIN LATERAL public.get_canonical_politician_header(
+ ranked.peer_person_id
+ ) AS header ON true
+ WHERE ranked.aligned_rank <= v_limit
+ OR ranked.differing_rank <= v_limit
+ ORDER BY
+ LEAST(ranked.aligned_rank, ranked.differing_rank),
+ ranked.aligned_rank,
+ ranked.differing_rank,
+ ranked.peer_person_id;
+END
+$function$;
+
+CREATE FUNCTION public.get_canonical_federal_voting_comparison_v1(
+ p_id uuid,
+ peer_id uuid,
+ scope_chamber text DEFAULT NULL,
+ scope_congress integer DEFAULT NULL,
+ comparison_filter text DEFAULT NULL,
+ result_limit integer DEFAULT 26,
+ result_offset integer DEFAULT 0
+)
+RETURNS TABLE (
+ roll_call_id text,
+ chamber text,
+ congress integer,
+ session smallint,
+ roll_call_number integer,
+ vote_date date,
+ question text,
+ vote_result text,
+ person_vote_cast text,
+ peer_vote_cast text,
+ comparison text,
+ source_name text,
+ source_url text,
+ source_updated_at timestamptz,
+ measures jsonb
+)
+LANGUAGE plpgsql
+STABLE
+SECURITY DEFINER
+SET search_path = ''
+AS $function$
+#variable_conflict use_column
+DECLARE
+ v_person_count integer;
+ v_person_id uuid;
+ v_peer_count integer;
+ v_peer_person_id uuid;
+ v_chamber text;
+ v_congress integer;
+ v_filter text;
+ v_limit integer;
+ v_offset integer;
+BEGIN
+ SELECT
+ count(DISTINCT resolved.person_id),
+ (array_agg(
+ DISTINCT resolved.person_id
+ ORDER BY resolved.person_id
+ ))[1]
+ INTO v_person_count, v_person_id
+ FROM public.get_canonical_person_legacy_ids(p_id) AS resolved;
+
+ SELECT
+ count(DISTINCT resolved.person_id),
+ (array_agg(
+ DISTINCT resolved.person_id
+ ORDER BY resolved.person_id
+ ))[1]
+ INTO v_peer_count, v_peer_person_id
+ FROM public.get_canonical_person_legacy_ids(peer_id) AS resolved;
+
+ IF v_person_count = 0 OR v_peer_count = 0 THEN
+ RETURN;
+ END IF;
+
+ IF v_person_count <> 1 OR v_peer_count <> 1 THEN
+ RAISE EXCEPTION
+ 'canonical voting-comparison resolution returned target % / peer % people',
+ v_person_count,
+ v_peer_count
+ USING ERRCODE = '21000';
+ END IF;
+
+ IF v_person_id = v_peer_person_id THEN
+ RAISE EXCEPTION
+ 'a federal voting comparison requires two different canonical people'
+ USING ERRCODE = '22023';
+ END IF;
+
+ IF (NULLIF(btrim(scope_chamber), '') IS NULL)
+ IS DISTINCT FROM (scope_congress IS NULL) THEN
+ RAISE EXCEPTION
+ 'scope_chamber and scope_congress must be supplied together'
+ USING ERRCODE = '22023';
+ END IF;
+
+ IF scope_chamber IS NULL THEN
+ SELECT target_vote.chamber, target_vote.congress
+ INTO v_chamber, v_congress
+ FROM public.canonical_verified_federal_roll_call_votes_v1 AS target_vote
+ JOIN public.person_roll_call_votes AS peer_vote
+ ON peer_vote.roll_call_source_record_id =
+ target_vote.roll_call_source_record_id
+ AND peer_vote.person_id = v_peer_person_id
+ AND peer_vote.vote_cast IN ('yea', 'nay')
+ JOIN public.people AS peer_person
+ ON peer_person.id = peer_vote.person_id
+ AND peer_person.status = 'active'
+ JOIN public.source_records AS peer_vote_source
+ ON peer_vote_source.id = peer_vote.source_record_id
+ AND peer_vote_source.person_id = peer_vote.person_id
+ AND peer_vote_source.record_type = 'person_roll_call_vote'
+ AND peer_vote_source.record_status = 'active'
+ AND peer_vote_source.retired_at IS NULL
+ AND peer_vote_source.verified_lane = 'verified'
+ AND peer_vote_source.source_system_key =
+ target_vote.source_system_key
+ AND peer_vote_source.source_catalog_slug =
+ target_vote.source_catalog_slug
+ AND peer_vote_source.source_endpoint_slug =
+ target_vote.source_endpoint_slug
+ AND peer_vote_source.source_url = target_vote.source_url
+ WHERE target_vote.person_id = v_person_id
+ AND target_vote.vote_cast IN ('yea', 'nay')
+ GROUP BY target_vote.chamber, target_vote.congress
+ ORDER BY
+ max(target_vote.vote_date) DESC,
+ target_vote.congress DESC,
+ target_vote.chamber
+ LIMIT 1;
+ ELSE
+ v_chamber := lower(btrim(scope_chamber));
+ v_congress := scope_congress;
+ END IF;
+
+ IF v_chamber IS NULL OR v_congress IS NULL THEN
+ RETURN;
+ END IF;
+
+ IF v_chamber NOT IN ('house', 'senate') OR v_congress <= 0 THEN
+ RAISE EXCEPTION
+ 'invalid federal voting-comparison scope: chamber %, Congress %',
+ v_chamber,
+ v_congress
+ USING ERRCODE = '22023';
+ END IF;
+
+ v_filter := NULLIF(lower(btrim(coalesce(comparison_filter, ''))), '');
+ IF v_filter IS NOT NULL AND v_filter NOT IN ('agree', 'differ') THEN
+ RAISE EXCEPTION
+ 'comparison_filter must be agree, differ, or null'
+ USING ERRCODE = '22023';
+ END IF;
+
+ v_limit := LEAST(GREATEST(COALESCE(result_limit, 26), 0), 51);
+ v_offset := LEAST(GREATEST(COALESCE(result_offset, 0), 0), 5000);
+
+ RETURN QUERY
+ WITH shared_votes AS (
+ SELECT
+ target_vote.roll_call_source_record_id,
+ target_vote.canonical_roll_call_key,
+ target_vote.chamber,
+ target_vote.congress,
+ target_vote.session,
+ target_vote.roll_call_number,
+ target_vote.vote_date,
+ target_vote.question,
+ target_vote.vote_result,
+ target_vote.vote_cast AS person_vote_cast,
+ peer_vote.vote_cast AS peer_vote_cast,
+ CASE
+ WHEN target_vote.vote_cast = peer_vote.vote_cast THEN 'agree'
+ ELSE 'differ'
+ END AS comparison,
+ target_vote.source_name,
+ target_vote.source_url,
+ GREATEST(
+ target_vote.source_updated_at,
+ peer_vote_source.last_seen_at
+ ) AS source_updated_at
+ FROM public.canonical_verified_federal_roll_call_votes_v1 AS target_vote
+ JOIN public.person_roll_call_votes AS peer_vote
+ ON peer_vote.roll_call_source_record_id =
+ target_vote.roll_call_source_record_id
+ AND peer_vote.person_id = v_peer_person_id
+ AND peer_vote.vote_cast IN ('yea', 'nay')
+ JOIN public.people AS peer_person
+ ON peer_person.id = peer_vote.person_id
+ AND peer_person.status = 'active'
+ JOIN public.source_records AS peer_vote_source
+ ON peer_vote_source.id = peer_vote.source_record_id
+ AND peer_vote_source.person_id = peer_vote.person_id
+ AND peer_vote_source.record_type = 'person_roll_call_vote'
+ AND peer_vote_source.record_status = 'active'
+ AND peer_vote_source.retired_at IS NULL
+ AND peer_vote_source.verified_lane = 'verified'
+ AND peer_vote_source.source_system_key =
+ target_vote.source_system_key
+ AND peer_vote_source.source_catalog_slug =
+ target_vote.source_catalog_slug
+ AND peer_vote_source.source_endpoint_slug =
+ target_vote.source_endpoint_slug
+ AND peer_vote_source.source_url = target_vote.source_url
+ WHERE target_vote.person_id = v_person_id
+ AND target_vote.chamber = v_chamber
+ AND target_vote.congress = v_congress
+ AND target_vote.vote_cast IN ('yea', 'nay')
+ )
+ SELECT
+ shared_vote.canonical_roll_call_key,
+ shared_vote.chamber,
+ shared_vote.congress,
+ shared_vote.session,
+ shared_vote.roll_call_number,
+ shared_vote.vote_date,
+ shared_vote.question,
+ shared_vote.vote_result,
+ CASE shared_vote.person_vote_cast
+ WHEN 'yea' THEN 'Yea'
+ WHEN 'nay' THEN 'Nay'
+ END,
+ CASE shared_vote.peer_vote_cast
+ WHEN 'yea' THEN 'Yea'
+ WHEN 'nay' THEN 'Nay'
+ END,
+ shared_vote.comparison,
+ shared_vote.source_name,
+ shared_vote.source_url,
+ shared_vote.source_updated_at,
+ COALESCE(measure_set.measures, '[]'::jsonb)
+ FROM shared_votes AS shared_vote
+ LEFT JOIN LATERAL (
+ SELECT jsonb_agg(
+ bounded_measure.presentation
+ ORDER BY
+ bounded_measure.measure_kind,
+ bounded_measure.measure_type,
+ bounded_measure.measure_number,
+ bounded_measure.canonical_measure_key
+ ) AS measures
+ FROM (
+ SELECT
+ measure.measure_kind,
+ measure.measure_type,
+ measure.measure_number,
+ measure.canonical_measure_key,
+ jsonb_build_object(
+ 'canonical_measure_key', measure.canonical_measure_key,
+ 'measure_kind', measure.measure_kind,
+ 'congress', measure.congress,
+ 'measure_type', measure.measure_type,
+ 'measure_number', measure.measure_number,
+ 'title', CASE
+ WHEN char_length(measure.title) > 1000
+ THEN left(measure.title, 999) || '…'
+ ELSE measure.title
+ END,
+ 'purpose', CASE
+ WHEN char_length(measure.purpose) > 2000
+ THEN left(measure.purpose, 1999) || '…'
+ ELSE measure.purpose
+ END,
+ 'official_url', CASE
+ WHEN measure.official_url LIKE 'https://www.congress.gov/%'
+ THEN measure.official_url
+ ELSE NULL
+ END,
+ 'source_name', measure_source_system.display_name,
+ 'observed_at', measure_source.last_seen_at
+ ) AS presentation
+ FROM public.legislative_roll_call_measure_links AS measure_link
+ JOIN public.legislative_measures AS measure
+ ON measure.source_record_id = measure_link.measure_source_record_id
+ AND measure.congress = shared_vote.congress
+ JOIN public.source_records AS measure_source
+ ON measure_source.id = measure.source_record_id
+ AND measure_source.source_system_key = 'congress-gov'
+ AND measure_source.source_record_key = measure.canonical_measure_key
+ AND measure_source.record_type = 'legislative_measure'
+ AND measure_source.person_id IS NULL
+ AND measure_source.legacy_politician_id IS NULL
+ AND measure_source.source_catalog_slug = 'congress-gov-api'
+ AND measure_source.source_endpoint_slug = 'api-v3'
+ AND measure_source.source_url = format(
+ 'https://api.congress.gov/v3/%s/%s/%s/%s',
+ measure.measure_kind,
+ measure.congress,
+ measure.measure_type,
+ measure.measure_number
+ )
+ AND measure_source.verified_lane = 'verified'
+ AND measure_source.record_status = 'active'
+ AND measure_source.retired_at IS NULL
+ JOIN public.source_systems AS measure_source_system
+ ON measure_source_system.key = measure_source.source_system_key
+ AND measure_source_system.source_kind = 'government'
+ AND measure_source_system.trust_level = 'official'
+ AND measure_source_system.verified = true
+ JOIN public.source_catalog_sources AS measure_catalog_source
+ ON measure_catalog_source.slug = measure_source.source_catalog_slug
+ AND measure_catalog_source.status = 'approved'
+ AND measure_catalog_source.repo_fit = 'wired'
+ AND measure_catalog_source.verified_lane = 'verified'
+ JOIN public.source_catalog_source_system_links AS measure_catalog_link
+ ON measure_catalog_link.source_slug = measure_catalog_source.slug
+ AND measure_catalog_link.source_system_key =
+ measure_source.source_system_key
+ AND measure_catalog_link.link_type = 'same_source'
+ JOIN public.source_catalog_endpoints AS measure_catalog_endpoint
+ ON measure_catalog_endpoint.source_slug =
+ measure_source.source_catalog_slug
+ AND measure_catalog_endpoint.endpoint_slug =
+ measure_source.source_endpoint_slug
+ AND measure_catalog_endpoint.status = 'approved'
+ WHERE measure_link.roll_call_source_record_id =
+ shared_vote.roll_call_source_record_id
+ AND measure_link.link_basis =
+ 'exact_official_measure_identifier'
+ ORDER BY
+ measure.measure_kind,
+ measure.measure_type,
+ measure.measure_number,
+ measure.canonical_measure_key
+ LIMIT 20
+ ) AS bounded_measure
+ ) AS measure_set ON true
+ WHERE v_filter IS NULL OR shared_vote.comparison = v_filter
+ ORDER BY
+ shared_vote.vote_date DESC,
+ shared_vote.canonical_roll_call_key,
+ shared_vote.roll_call_source_record_id
+ LIMIT v_limit
+ OFFSET v_offset;
+END
+$function$;
+
+REVOKE EXECUTE ON FUNCTION public.get_canonical_federal_voting_summary_v1(
+ uuid
+) FROM PUBLIC;
+REVOKE EXECUTE ON FUNCTION public.get_canonical_federal_voting_alignment_v1(
+ uuid,
+ text,
+ integer,
+ integer
+) FROM PUBLIC;
+REVOKE EXECUTE ON FUNCTION public.get_canonical_federal_voting_comparison_v1(
+ uuid,
+ uuid,
+ text,
+ integer,
+ text,
+ integer,
+ integer
+) FROM PUBLIC;
+
+GRANT EXECUTE ON FUNCTION public.get_canonical_federal_voting_summary_v1(
+ uuid
+) TO anon, authenticated;
+GRANT EXECUTE ON FUNCTION public.get_canonical_federal_voting_alignment_v1(
+ uuid,
+ text,
+ integer,
+ integer
+) TO anon, authenticated;
+GRANT EXECUTE ON FUNCTION public.get_canonical_federal_voting_comparison_v1(
+ uuid,
+ uuid,
+ text,
+ integer,
+ text,
+ integer,
+ integer
+) TO anon, authenticated;
+
+DO $migration_postconditions$
+DECLARE
+ v_function_oid oid;
+ v_signature text;
+BEGIN
+ FOREACH v_signature IN ARRAY ARRAY[
+ 'public.get_canonical_federal_voting_summary_v1(uuid)',
+ 'public.get_canonical_federal_voting_alignment_v1(uuid,text,integer,integer)',
+ 'public.get_canonical_federal_voting_comparison_v1(uuid,uuid,text,integer,text,integer,integer)'
+ ] LOOP
+ v_function_oid := to_regprocedure(v_signature);
+
+ IF v_function_oid IS NULL
+ OR NOT (
+ SELECT function_state.prosecdef
+ FROM pg_proc AS function_state
+ WHERE function_state.oid = v_function_oid
+ )
+ OR NOT has_function_privilege('anon', v_function_oid, 'EXECUTE')
+ OR NOT has_function_privilege(
+ 'authenticated',
+ v_function_oid,
+ 'EXECUTE'
+ )
+ OR EXISTS (
+ SELECT 1
+ FROM aclexplode(
+ (SELECT proacl FROM pg_proc WHERE oid = v_function_oid)
+ ) AS acl
+ WHERE acl.grantee = 0
+ AND acl.privilege_type = 'EXECUTE'
+ ) THEN
+ RAISE EXCEPTION
+ 'federal voting analytics RPC security contract was not installed: %',
+ v_signature
+ USING ERRCODE = '42501';
+ END IF;
+ END LOOP;
+
+ IF has_table_privilege(
+ 'anon',
+ 'public.canonical_verified_federal_roll_call_votes_v1',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'authenticated',
+ 'public.canonical_verified_federal_roll_call_votes_v1',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'service_role',
+ 'public.canonical_verified_federal_roll_call_votes_v1',
+ 'SELECT'
+ )
+ OR has_table_privilege('anon', 'public.source_records', 'SELECT')
+ OR has_table_privilege('authenticated', 'public.source_records', 'SELECT')
+ OR has_table_privilege(
+ 'anon',
+ 'public.legislative_roll_calls',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'authenticated',
+ 'public.legislative_roll_calls',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'anon',
+ 'public.person_roll_call_votes',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'authenticated',
+ 'public.person_roll_call_votes',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'anon',
+ 'public.legislative_measures',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'authenticated',
+ 'public.legislative_measures',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'anon',
+ 'public.legislative_roll_call_measure_links',
+ 'SELECT'
+ )
+ OR has_table_privilege(
+ 'authenticated',
+ 'public.legislative_roll_call_measure_links',
+ 'SELECT'
+ ) THEN
+ RAISE EXCEPTION
+ 'federal voting analytics opened an internal view or private table'
+ USING ERRCODE = '42501';
+ END IF;
+END
+$migration_postconditions$;
+
+INSERT INTO public.schema_migrations (
+ migration_key,
+ migration_version,
+ description,
+ metadata
+)
+VALUES (
+ '0038_canonical_federal_voting_analytics',
+ 38,
+ 'Expose bounded canonical federal vote summaries, alignment rankings, and inspectable pair evidence.',
+ jsonb_build_object(
+ 'internal_read_model',
+ 'canonical_verified_federal_roll_call_votes_v1',
+ 'public_rpcs', jsonb_build_array(
+ 'get_canonical_federal_voting_summary_v1',
+ 'get_canonical_federal_voting_alignment_v1',
+ 'get_canonical_federal_voting_comparison_v1'
+ ),
+ 'official_sources', jsonb_build_array(
+ 'house-clerk-roll-call-xml',
+ 'senate-roll-call-xml'
+ ),
+ 'identity_key', 'person_id',
+ 'alignment_vote_casts', jsonb_build_array('yea', 'nay'),
+ 'alignment_minimum_shared_votes', 10,
+ 'maximum_ranked_peers_per_side', 12,
+ 'maximum_comparison_page_size', 51,
+ 'maximum_measures_per_comparison_vote', 20,
+ 'legacy_vote_rows_included', false,
+ 'direct_private_table_access_created', false,
+ 'scraper_write_contract_changed', false,
+ 'scraper_preflight_required', true
+ )
+);
+
+NOTIFY pgrst, 'reload schema';
+
+COMMIT;
diff --git a/scraper/schema_preflight.py b/scraper/schema_preflight.py
index 2a662f3..6451b69 100644
--- a/scraper/schema_preflight.py
+++ b/scraper/schema_preflight.py
@@ -1,6 +1,6 @@
ZERO_UUID = "00000000-0000-0000-0000-000000000000"
-REQUIRED_MIGRATION_KEY = "0037_congress_gov_measure_read_surface"
-REQUIRED_MIGRATION_FILE = "0037_congress_gov_measure_read_surface.sql"
+REQUIRED_MIGRATION_KEY = "0038_canonical_federal_voting_analytics"
+REQUIRED_MIGRATION_FILE = "0038_canonical_federal_voting_analytics.sql"
REQUIRED_COLUMN_CHECKS = [
(
@@ -234,6 +234,34 @@
},
"get_canonical_voting_records_v3(uuid, integer, integer, text)",
),
+ (
+ "get_canonical_federal_voting_summary_v1",
+ {"p_id": ZERO_UUID},
+ "get_canonical_federal_voting_summary_v1(uuid)",
+ ),
+ (
+ "get_canonical_federal_voting_alignment_v1",
+ {
+ "p_id": ZERO_UUID,
+ "scope_chamber": None,
+ "scope_congress": None,
+ "result_limit_per_side": 1,
+ },
+ "get_canonical_federal_voting_alignment_v1(uuid, text, integer, integer)",
+ ),
+ (
+ "get_canonical_federal_voting_comparison_v1",
+ {
+ "p_id": ZERO_UUID,
+ "peer_id": ZERO_UUID,
+ "scope_chamber": None,
+ "scope_congress": None,
+ "comparison_filter": None,
+ "result_limit": 1,
+ "result_offset": 0,
+ },
+ "get_canonical_federal_voting_comparison_v1(uuid, uuid, text, integer, text, integer, integer)",
+ ),
(
"get_canonical_media_mentions",
{"p_id": ZERO_UUID, "result_limit": 1, "result_offset": 0},
diff --git a/scraper/tests/test_federal_voting_analytics_migration.py b/scraper/tests/test_federal_voting_analytics_migration.py
new file mode 100644
index 0000000..a1f04e9
--- /dev/null
+++ b/scraper/tests/test_federal_voting_analytics_migration.py
@@ -0,0 +1,270 @@
+import re
+import unittest
+from pathlib import Path
+
+
+class FederalVotingAnalyticsMigrationTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ migration_path = (
+ Path(__file__).resolve().parents[2]
+ / "migrations"
+ / "0038_canonical_federal_voting_analytics.sql"
+ )
+ cls.sql = migration_path.read_text(encoding="utf-8")
+
+ view_start = cls.sql.index(
+ "CREATE VIEW public.canonical_verified_federal_roll_call_votes_v1"
+ )
+ view_end = cls.sql.index(
+ "REVOKE ALL ON TABLE public.canonical_verified_federal_roll_call_votes_v1",
+ view_start,
+ )
+ cls.view_sql = cls.sql[view_start:view_end]
+
+ cls.function_sql = {}
+ for function_name in (
+ "get_canonical_federal_voting_summary_v1",
+ "get_canonical_federal_voting_alignment_v1",
+ "get_canonical_federal_voting_comparison_v1",
+ ):
+ function_start = cls.sql.index(
+ f"CREATE FUNCTION public.{function_name}"
+ )
+ function_end = cls.sql.index("$function$;", function_start)
+ cls.function_sql[function_name] = cls.sql[
+ function_start:function_end
+ ]
+
+ def test_requires_the_complete_measure_aware_vote_surface(self):
+ self.assertIn(
+ "migration_key = '0037_congress_gov_measure_read_surface'",
+ self.sql,
+ )
+ self.assertIn("migration_version = 37", self.sql)
+ self.assertIn(
+ "migration 0037_congress_gov_measure_read_surface must be applied first",
+ self.sql,
+ )
+
+ def test_internal_read_model_is_security_invoker_owner_only_and_pushdown_safe(self):
+ self.assertIn("security_invoker = true", self.view_sql)
+ self.assertNotIn("security_barrier = true", self.view_sql)
+ self.assertIn("indexed person/scope filters must be eligible for pushdown", self.sql)
+ self.assertIn(
+ "REVOKE ALL ON TABLE "
+ "public.canonical_verified_federal_roll_call_votes_v1\n"
+ " FROM PUBLIC, anon, authenticated, service_role;",
+ self.sql,
+ )
+ self.assertNotIn(
+ "GRANT SELECT ON TABLE "
+ "public.canonical_verified_federal_roll_call_votes_v1",
+ self.sql,
+ )
+
+ def test_internal_read_model_accepts_only_active_reviewed_official_facts(self):
+ for contract in (
+ "person.status = 'active'",
+ "person_vote_source.record_type = 'person_roll_call_vote'",
+ "person_vote_source.record_status = 'active'",
+ "person_vote_source.retired_at IS NULL",
+ "person_vote_source.verified_lane = 'verified'",
+ "roll_call_source.record_type = 'legislative_roll_call'",
+ "roll_call_source.record_status = 'active'",
+ "roll_call_source.retired_at IS NULL",
+ "roll_call_source.verified_lane = 'verified'",
+ "source_system.source_kind = 'government'",
+ "source_system.trust_level = 'official'",
+ "source_system.verified = true",
+ "catalog_source.status = 'approved'",
+ "catalog_source.repo_fit = 'wired'",
+ "catalog_source.verified_lane = 'verified'",
+ "catalog_endpoint.status = 'approved'",
+ ):
+ self.assertIn(contract, self.view_sql)
+
+ for exact_source_contract in (
+ "roll_call_source.source_system_key = 'house-clerk'",
+ "roll_call_source.source_catalog_slug = 'house-clerk-roll-call-xml'",
+ "roll_call_source.source_endpoint_slug = 'evs-roll-call-feed'",
+ "roll_call.canonical_roll_call_key ~ '^house:'",
+ "roll_call_source.source_system_key = 'senate-lis'",
+ "roll_call_source.source_catalog_slug = 'senate-roll-call-xml'",
+ "roll_call_source.source_endpoint_slug = 'lis-roll-call-feed'",
+ "roll_call.canonical_roll_call_key ~ '^senate:'",
+ ):
+ self.assertIn(exact_source_contract, self.view_sql)
+
+ def test_summary_is_role_scoped_and_keeps_participation_categories_visible(self):
+ summary = self.function_sql["get_canonical_federal_voting_summary_v1"]
+ self.assertIn("public.get_canonical_person_legacy_ids(p_id)", summary)
+ self.assertIn("person.status = 'active'", summary)
+ self.assertIn("GROUP BY vote.chamber, vote.congress", summary)
+ self.assertIn("vote.vote_cast <> 'not_voting'", summary)
+ self.assertIn("vote.vote_cast IN ('yea', 'nay')", summary)
+ self.assertIn("vote.vote_cast = 'present'", summary)
+ self.assertIn("vote.vote_cast = 'not_voting'", summary)
+ self.assertIn(" 10,", summary)
+ self.assertIn("LIMIT 20", summary)
+
+ def test_alignment_is_exact_bounded_and_has_a_real_sample_floor(self):
+ alignment = self.function_sql[
+ "get_canonical_federal_voting_alignment_v1"
+ ]
+ for contract in (
+ "vote.person_id = v_person_id",
+ "vote.chamber = v_chamber",
+ "vote.congress = v_congress",
+ "vote.vote_cast IN ('yea', 'nay')",
+ "peer_vote.roll_call_source_record_id =",
+ "target_vote.roll_call_source_record_id",
+ "peer_vote.person_id <> v_person_id",
+ "peer_vote.vote_cast IN ('yea', 'nay')",
+ "HAVING count(*) >= 10",
+ "score.agreement_rate DESC",
+ "score.agreement_rate,",
+ "ranked.aligned_rank <= v_limit",
+ "ranked.differing_rank <= v_limit",
+ "GREATEST(COALESCE(result_limit_per_side, 6), 1)",
+ " 12",
+ ):
+ self.assertIn(contract, alignment)
+
+ self.assertIn(
+ "public.get_canonical_politician_header(\n"
+ " ranked.peer_person_id",
+ alignment,
+ )
+ self.assertNotIn("public.voting_records", alignment)
+
+ def test_optimized_peer_joins_preserve_the_verified_source_contract(self):
+ for function_name in (
+ "get_canonical_federal_voting_alignment_v1",
+ "get_canonical_federal_voting_comparison_v1",
+ ):
+ function_sql = self.function_sql[function_name]
+ for contract in (
+ "peer_person.status = 'active'",
+ "peer_vote_source.id = peer_vote.source_record_id",
+ "peer_vote_source.person_id = peer_vote.person_id",
+ "peer_vote_source.record_type = 'person_roll_call_vote'",
+ "peer_vote_source.record_status = 'active'",
+ "peer_vote_source.retired_at IS NULL",
+ "peer_vote_source.verified_lane = 'verified'",
+ "peer_vote_source.source_system_key =",
+ "target_vote.source_system_key",
+ "peer_vote_source.source_catalog_slug =",
+ "target_vote.source_catalog_slug",
+ "peer_vote_source.source_endpoint_slug =",
+ "target_vote.source_endpoint_slug",
+ "peer_vote_source.source_url = target_vote.source_url",
+ ):
+ self.assertIn(contract, function_sql)
+
+ def test_pairwise_evidence_matches_the_metric_and_is_paginated(self):
+ comparison = self.function_sql[
+ "get_canonical_federal_voting_comparison_v1"
+ ]
+ for contract in (
+ "v_person_id = v_peer_person_id",
+ "peer_vote.person_id = v_peer_person_id",
+ "target_vote.person_id = v_person_id",
+ "target_vote.chamber = v_chamber",
+ "target_vote.congress = v_congress",
+ "target_vote.vote_cast IN ('yea', 'nay')",
+ "peer_vote.vote_cast IN ('yea', 'nay')",
+ "WHEN target_vote.vote_cast = peer_vote.vote_cast THEN 'agree'",
+ "v_filter NOT IN ('agree', 'differ')",
+ "WHERE v_filter IS NULL OR shared_vote.comparison = v_filter",
+ "GREATEST(COALESCE(result_limit, 26), 0), 51",
+ "GREATEST(COALESCE(result_offset, 0), 0), 5000",
+ "LIMIT v_limit",
+ "OFFSET v_offset",
+ ):
+ self.assertIn(contract, comparison)
+
+ self.assertNotIn("public.voting_records", comparison)
+
+ def test_pairwise_evidence_exposes_only_bounded_verified_measure_metadata(self):
+ comparison = self.function_sql[
+ "get_canonical_federal_voting_comparison_v1"
+ ]
+ for contract in (
+ "measure_link.link_basis =",
+ "'exact_official_measure_identifier'",
+ "measure_source.source_system_key = 'congress-gov'",
+ "measure_source.record_status = 'active'",
+ "measure_source.retired_at IS NULL",
+ "measure_source.verified_lane = 'verified'",
+ "measure_catalog_source.status = 'approved'",
+ "measure_catalog_source.repo_fit = 'wired'",
+ "measure_catalog_endpoint.status = 'approved'",
+ "measure.official_url LIKE 'https://www.congress.gov/%'",
+ "LIMIT 20",
+ ):
+ self.assertIn(contract, comparison)
+
+ for private_presentation in (
+ "raw_payload_ref",
+ "payload_hash",
+ "measure.metadata",
+ "measure_link.metadata",
+ "measure_source.metadata",
+ ):
+ self.assertNotIn(private_presentation, comparison)
+
+ def test_public_contract_is_security_definer_without_private_table_grants(self):
+ for function_name, signature in (
+ ("get_canonical_federal_voting_summary_v1", "uuid"),
+ (
+ "get_canonical_federal_voting_alignment_v1",
+ "uuid,text,integer,integer",
+ ),
+ (
+ "get_canonical_federal_voting_comparison_v1",
+ "uuid,uuid,text,integer,text,integer,integer",
+ ),
+ ):
+ function_sql = self.function_sql[function_name]
+ self.assertIn("STABLE", function_sql)
+ self.assertIn("SECURITY DEFINER", function_sql)
+ self.assertIn("SET search_path = ''", function_sql)
+ self.assertIn(
+ f"public.{function_name}({signature})",
+ self.sql.replace("\n", "").replace(" ", ""),
+ )
+
+ for table in (
+ "source_records",
+ "legislative_roll_calls",
+ "person_roll_call_votes",
+ "legislative_measures",
+ "legislative_roll_call_measure_links",
+ ):
+ self.assertNotIn(f"GRANT SELECT ON TABLE public.{table}", self.sql)
+
+ self.assertGreaterEqual(self.sql.count(") TO anon, authenticated;"), 3)
+
+ def test_migration_is_read_only_apart_from_its_forward_marker(self):
+ mutations = re.findall(
+ r"\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+public\.([a-z0-9_]+)",
+ self.sql,
+ flags=re.IGNORECASE,
+ )
+ self.assertEqual(["schema_migrations"], [table.lower() for table in mutations])
+
+ def test_records_marker_and_advances_preflight(self):
+ self.assertIn(
+ "'0038_canonical_federal_voting_analytics',\n 38,",
+ self.sql,
+ )
+ self.assertIn("'legacy_vote_rows_included', false", self.sql)
+ self.assertIn("'scraper_write_contract_changed', false", self.sql)
+ self.assertIn("'scraper_preflight_required', true", self.sql)
+ self.assertIn("NOTIFY pgrst, 'reload schema';", self.sql)
+ self.assertTrue(self.sql.rstrip().endswith("COMMIT;"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scraper/tests/test_federal_voting_analytics_ui.py b/scraper/tests/test_federal_voting_analytics_ui.py
new file mode 100644
index 0000000..67924c5
--- /dev/null
+++ b/scraper/tests/test_federal_voting_analytics_ui.py
@@ -0,0 +1,116 @@
+import unittest
+from pathlib import Path
+
+
+class FederalVotingAnalyticsUiTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ repo_root = Path(__file__).resolve().parents[2]
+ cls.client = (
+ repo_root / "frontend" / "src" / "lib" / "votingAnalytics.ts"
+ ).read_text(encoding="utf-8")
+ cls.panel = (
+ repo_root
+ / "frontend"
+ / "src"
+ / "app"
+ / "[politician_id]"
+ / "VotingAnalyticsPanel.tsx"
+ ).read_text(encoding="utf-8")
+ cls.connections_client = (
+ repo_root / "frontend" / "src" / "lib" / "connections.ts"
+ ).read_text(encoding="utf-8")
+ cls.connections_tab = (
+ repo_root
+ / "frontend"
+ / "src"
+ / "app"
+ / "[politician_id]"
+ / "ConnectionsTab.tsx"
+ ).read_text(encoding="utf-8")
+ cls.politician_client = (
+ repo_root
+ / "frontend"
+ / "src"
+ / "app"
+ / "[politician_id]"
+ / "PoliticianClient.tsx"
+ ).read_text(encoding="utf-8")
+ cls.routes = (
+ repo_root / "frontend" / "src" / "lib" / "routes.ts"
+ ).read_text(encoding="utf-8")
+
+ def test_client_calls_all_three_versioned_analytics_rpcs(self):
+ for rpc in (
+ "get_canonical_federal_voting_summary_v1",
+ "get_canonical_federal_voting_alignment_v1",
+ "get_canonical_federal_voting_comparison_v1",
+ ):
+ self.assertIn(f"'{rpc}'", self.client)
+
+ for boundary in (
+ "isUuid(personId)",
+ "isUuid(peerPersonId)",
+ "chamberValue(row.chamber)",
+ "boundedRate(row.participation_rate)",
+ "safeHttpUrl(optionalText(row.source_url))",
+ "normalizeLegislativeMeasures(row.measures)",
+ "result_limit: range.pageSize + 1",
+ ):
+ self.assertIn(boundary, self.client)
+
+ def test_profile_exposes_a_complete_analytics_and_evidence_experience(self):
+ for presentation in (
+ "Federal voting analytics",
+ "Covered official votes",
+ "Participation",
+ "Yea / Nay",
+ "Present / Not voting",
+ "Official voting alignment",
+ "Most aligned",
+ "Most often differed",
+ "Compare shared votes",
+ "Official pairwise evidence",
+ "Agreements",
+ "Differences",
+ "Congress.gov",
+ "Source: {record.source_name}",
+ ):
+ self.assertIn(presentation, self.panel)
+
+ self.assertIn("alignment_minimum_shared_votes", self.panel)
+ self.assertIn("Present and Not Voting do not affect alignment", self.panel)
+ self.assertIn("PaginationControls", self.panel)
+ self.assertNotIn("dangerouslySetInnerHTML", self.panel)
+
+ def test_connections_prefers_official_data_and_separates_legacy_fallback(self):
+ official_call = self.connections_client.index(
+ "fetchLatestFederalVotingAlignment(politicianId)"
+ )
+ official_guard = self.connections_client.index(
+ "if (official.summary)", official_call
+ )
+ legacy_call = self.connections_client.index(
+ "rpc('get_covoting'", official_guard
+ )
+ self.assertLess(official_call, official_guard)
+ self.assertLess(official_guard, legacy_call)
+
+ for presentation in (
+ "Official Federal Voting Alignment",
+ "State & Historical Co-voting",
+ "It is not blended into the official federal alignment metric.",
+ "Inspect shared votes",
+ "votingComparisonPath(currentPoliticianId, c.politician_id)",
+ ):
+ self.assertIn(presentation, self.connections_tab)
+
+ def test_comparison_links_are_shareable_and_tab_is_visible(self):
+ self.assertIn("export function votingComparisonPath", self.routes)
+ self.assertIn("tab: 'votes'", self.routes)
+ self.assertIn("compare: peerId", self.routes)
+ self.assertIn("Voting & Analytics", self.politician_client)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scraper/tests/test_schema_preflight.py b/scraper/tests/test_schema_preflight.py
index e493cee..1e23f03 100644
--- a/scraper/tests/test_schema_preflight.py
+++ b/scraper/tests/test_schema_preflight.py
@@ -87,13 +87,13 @@ def execute_supabase(self, operation, description, retries=3):
class SchemaPreflightTests(unittest.TestCase):
- def test_preflight_requires_the_congress_gov_measure_read_surface_migration(self):
+ def test_preflight_requires_the_federal_voting_analytics_migration(self):
self.assertEqual(
- "0037_congress_gov_measure_read_surface",
+ "0038_canonical_federal_voting_analytics",
REQUIRED_MIGRATION_KEY,
)
self.assertEqual(
- "0037_congress_gov_measure_read_surface.sql",
+ "0038_canonical_federal_voting_analytics.sql",
REQUIRED_MIGRATION_FILE,
)
@@ -220,6 +220,9 @@ def test_success_checks_required_tables_and_rpcs(self):
"get_canonical_voting_records",
"get_canonical_voting_records_v2",
"get_canonical_voting_records_v3",
+ "get_canonical_federal_voting_summary_v1",
+ "get_canonical_federal_voting_alignment_v1",
+ "get_canonical_federal_voting_comparison_v1",
"get_canonical_media_mentions",
"get_canonical_person_office_terms",
):
@@ -259,7 +262,7 @@ def test_missing_required_marker_fails_before_etl(self):
def test_predecessor_marker_cannot_satisfy_the_current_preflight(self):
client = FakeSupabase(
- migration_markers={"0036_congress_gov_scheduled_enablement"}
+ migration_markers={"0037_congress_gov_measure_read_surface"}
)
loader = FakeLoader(client)