Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/daily-metrics.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,14 @@ jobs:
UPSTASH_COUNTERS_REST_TOKEN: ${{ secrets.UPSTASH_COUNTERS_REST_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DIGEST_ISSUE_NUMBER: ${{ steps.setup.outputs.issue_num }}
# Flagged (not fixed) in PR #87: this workflow never set VERCEL_ENV,
# so lib/upstash.ts's keyPrefix() resolved to 'dev' here while the
# live site's routes write under 'production:' (VERCEL_ENV is
# Vercel-injected at request time, not something a GitHub Actions
# runner has). Every readUsageWindow/readMcpClientDay call was
# silently reading an empty 'dev:' keyspace instead of the real
# counters - the digest could only ever report zeros. Same fix
# scripts/tenant-admin.mjs's own header comment already documents
# for laptop-shell CLI runs: export VERCEL_ENV=production explicitly.
VERCEL_ENV: production
run: npx tsx scripts/daily-metrics.mjs
89 changes: 89 additions & 0 deletions .github/workflows/hot-bills.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
name: Hot-bill refresh

# Twice-daily, refresh-ONLY pass between nightly bill syncs (audit §4 Alt B /
# §5 item 3, pipeline-audit.md): the nightly sync (sync-bills.yml, 07:30 UTC)
# structurally can't reflect a floor vote or markup that happens mid-day
# until the next morning. This job re-fetches the ~100 most-recently-updated
# Congress.gov bills and refreshes status/last_action_date/urgency for
# whichever of them are ALREADY in the corpus - decodes nothing, so a
# brand-new bill still waits for the nightly sync's decode-before-publish
# gate (scripts/hot-bills.mjs's header comment has the full reasoning).
# Zero Anthropic usage: no ANTHROPIC_API_KEY in this workflow at all.

on:
schedule:
# 17:00 and 22:00 UTC (~1pm/6pm ET) - both inside the US legislative day,
# after floor/committee activity has had time to post to Congress.gov,
# and well clear of the 07:30 UTC nightly sync on both sides.
- cron: '0 17 * * *'
- cron: '0 22 * * *'
workflow_dispatch:

permissions:
contents: write

concurrency:
# Shared with sync-bills.yml/refresh-legislators.yml so this can never race
# the nightly sync's commit - same reasoning as those two workflows already
# sharing this group.
group: data-sync
cancel-in-progress: false

jobs:
hot-refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
# scripts/hot-bills.mjs and scripts/congress-fetch.mjs are stdlib-only
# (Node's global fetch + lib/urgency.mjs, a plain .mjs with no npm
# deps) - same "the runner's node suffices, no npm ci" posture as
# refresh-legislators.yml and scripts/verify-deploy.mjs.
with:
node-version: 24
- name: Refresh hot bills
env:
CONGRESS_API_KEY: ${{ secrets.CONGRESS_API_KEY }}
run: node scripts/hot-bills.mjs
- name: Commit data
id: commit
# Same oravan-sync author pattern as sync-bills.yml/refresh-
# legislators.yml - the author email must map to the Vercel-linked
# GitHub account or Vercel BLOCKs the auto-deploy (docs/solutions/
# vercel-bot-push-blocked-deploys.md).
run: |
git config user.name "oravan-sync"
git config user.email "223600121+cm2489@users.noreply.github.com"
git add data/
if git diff --cached --quiet; then
echo "no changes"
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
git commit -m "chore(data): hot-bill refresh $(date -u +%FT%HZ)"
# This job only ever touches data/bills.json (refresh-only, no ES/
# coverage/sync-state writes) - disjoint from sync-bills.yml's and
# refresh-legislators.yml's own file sets, but the checked-out base
# can still go stale behind either of them in the shared data-sync
# concurrency group. Rebase onto the latest main and retry, same
# pattern as the other two workflows.
for i in 1 2 3 4 5; do
if git push; then
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "push rejected, rebasing onto latest main (attempt $i)"
git fetch origin main
git rebase origin/main
done
exit 1
- name: Verify the deploy landed
# Same dead-man's-switch as sync-bills.yml/refresh-legislators.yml -
# poll production until it serves the SHA we just pushed. Skips with
# a notice until the PROD_URL repo variable is set.
if: steps.commit.outputs.changed == 'true'
env:
PROD_URL: ${{ vars.PROD_URL }}
EXPECT_SHA: ${{ steps.commit.outputs.sha }}
run: node scripts/verify-deploy.mjs
13 changes: 11 additions & 2 deletions .github/workflows/sync-bills.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
inputs:
max_new_decodes:
description: 'Max new bills to AI-decode this run'
default: '40'
default: '120'

permissions:
contents: write
Expand Down Expand Up @@ -35,7 +35,7 @@ jobs:
env:
CONGRESS_API_KEY: ${{ secrets.CONGRESS_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
MAX_NEW_DECODES: ${{ inputs.max_new_decodes || '40' }}
MAX_NEW_DECODES: ${{ inputs.max_new_decodes || '120' }}
run: node scripts/sync-bills.mjs
- name: Backfill coverage search inputs
# Drains press_names/news_query for bills decoded before search-input
Expand Down Expand Up @@ -143,6 +143,15 @@ jobs:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
UPSTASH_CACHE_REST_URL: ${{ secrets.UPSTASH_CACHE_REST_URL }}
UPSTASH_CACHE_REST_TOKEN: ${{ secrets.UPSTASH_CACHE_REST_TOKEN }}
# 2026-07-16 metrics-env fix (same root cause as PR #87's "Flagged,
# not fixed" note on daily-metrics.yml): this workflow never set
# VERCEL_ENV either, so lib/scriptcache.ts's keys were written under
# keyPrefix()'s 'dev' fallback instead of 'production' — every combo
# this step ever cached since PREGEN_ENABLED was armed (2026-07-12)
# landed in a keyspace app/api/script/route.ts's live requests
# (VERCEL_ENV=production, Vercel-injected) never read, so every
# pregen run has been pure spend with zero possible cache hits.
VERCEL_ENV: production
run: |
if [ "$PREGEN_ENABLED" != "true" ]; then
echo "::notice::pregen SKIPPED - repo variable PREGEN_ENABLED is not 'true'. \$0 spent tonight. See the S21 PR's Arming checklist to turn this on."
Expand Down
2 changes: 1 addition & 1 deletion app/[locale]/bills/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default async function BillsPage({ params }: { params: Promise<{ locale:
<NewsLens bills={news} />
</div>
)}
<BillsBrowser bills={getTeasers(locale)} checkedAt={freshness.checkedAt} />
<BillsBrowser bills={getTeasers(locale)} freshness={freshness} />
</div>
);
}
2 changes: 1 addition & 1 deletion app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
</div>
) : quiet ? (
<div className="mt-6">
<UrgencyEmptyState checkedAt={freshness.checkedAt} />
<UrgencyEmptyState {...freshness} />
</div>
) : null}
</section>
Expand Down
2 changes: 1 addition & 1 deletion app/[locale]/reps/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export default async function RepsPage({
</div>
) : (
<div className="mt-5">
<UrgencyEmptyState checkedAt={freshness.checkedAt} />
<UrgencyEmptyState {...freshness} />
</div>
)}
<Link
Expand Down
5 changes: 3 additions & 2 deletions components/BillsBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Search, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { BAND_SIZES, CATEGORIES, type UrgencyBand } from '@/lib/taxonomy';
import { setPrefs, usePrefs } from '@/lib/local';
import type { FreshnessSignals } from '@/lib/freshness-state';
import type { FeedTeaser } from '@/lib/types';
import { BillCard } from './BillCard';
import { UrgencyEmptyState } from './UrgencyEmptyState';
Expand All @@ -16,7 +17,7 @@ const BANDS: UrgencyBand[] = ['now', 'moving', 'radar'];
stays one "Show all" away. Derived from BAND_SIZES so the two can't drift. */
const BAND_CAP = BAND_SIZES.now;

export function BillsBrowser({ bills, checkedAt }: { bills: FeedTeaser[]; checkedAt: string }) {
export function BillsBrowser({ bills, freshness }: { bills: FeedTeaser[]; freshness: FreshnessSignals }) {
const t = useTranslations();
const [query, setQuery] = useState('');
const [expanded, setExpanded] = useState<Partial<Record<UrgencyBand, boolean>>>({});
Expand Down Expand Up @@ -195,7 +196,7 @@ export function BillsBrowser({ bills, checkedAt }: { bills: FeedTeaser[]; checke
</h2>
<p className="mt-0.5 text-sm text-ink-soft">{t(`bills.bandSub.${band}`)}</p>
<div className="mt-4">
<UrgencyEmptyState checkedAt={checkedAt} />
<UrgencyEmptyState {...freshness} />
</div>
</section>
);
Expand Down
17 changes: 10 additions & 7 deletions components/UrgencyEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useSyncExternalStore } from 'react';
import { useFormatter, useTranslations } from 'next-intl';
import { emptyStateVerdict } from '@/lib/freshness-state';
import { emptyStateVerdict, type FreshnessSignals } from '@/lib/freshness-state';

// The React-idiomatic hydration gate: server snapshot (and the hydration
// render) reads false, the first client snapshot reads true. No state, no
Expand All @@ -20,10 +20,13 @@ const useHydrated = () =>
* bills clearing the urgency floor. Which of the two honest messages shows
* depends on whether the data itself is trustworthy right now:
* - quiet week: floor cleared no bills, but the corpus was checked recently
* - a real quiet week, said plainly instead of backfilled from rank.
* - data stale: the last successful check is older than the claim window,
* so an empty list might just mean "we haven't looked lately" - the copy
* says that, not "quiet."
* AND the sync cursor/corpus itself shows real recent progress - a real
* quiet week, said plainly instead of backfilled from rank.
* - data stale: either the last successful check is older than its claim
* window, or the sync cursor / newest known activity has gone dark past
* the wider dead window (lib/freshness-state.ts's emptyStateVerdict has
* the full threshold reasoning) - an empty list might just mean "we
* haven't actually looked lately," and the copy says that, not "quiet."
*
* This must stay a client component: the site is largely static-generated,
* so a server-rendered verdict freezes at build time and a dead sync would
Expand All @@ -35,7 +38,7 @@ const useHydrated = () =>
* the visitor's: baked "quiet week" HTML would flash (or, with JS off,
* permanently claim) quiet on long-dead data.
*/
export function UrgencyEmptyState({ checkedAt }: { checkedAt: string }) {
export function UrgencyEmptyState({ checkedAt, completeThrough, newestAction }: FreshnessSignals) {
const t = useTranslations('freshness');
const format = useFormatter();
const hydrated = useHydrated();
Expand All @@ -51,7 +54,7 @@ export function UrgencyEmptyState({ checkedAt }: { checkedAt: string }) {
);
}

const staleVerdict = emptyStateVerdict(checkedAt) === 'data_stale';
const staleVerdict = emptyStateVerdict({ checkedAt, completeThrough, newestAction }) === 'data_stale';
return (
<div role="status" className="rounded-card border border-line bg-paper-deep p-6">
<p className="font-display text-lg font-semibold text-ink">
Expand Down
11 changes: 7 additions & 4 deletions lib/core/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,11 +565,14 @@ export function whatsMoving(params: WhatsMovingParams, locale: Locale) {
* AE3/KTD-2 honesty rule, reusing lib/freshness-state.ts's collapse rather
* than re-deriving it (that file's own doc comment names this exact
* tool): an empty result reads as a genuine "quiet week" only while the
* nightly pipeline itself looks alive. A stale or dead pipeline must never
* be dressed up as "nothing to act on this week" - that would hand an
* agent a fact about our sync health disguised as a fact about Congress.
* nightly pipeline itself looks alive AND the sync cursor/corpus itself
* shows real recent progress (not just "the job executed" - see
* emptyStateVerdict's own doc comment for why both signals matter). A
* stale or dead pipeline must never be dressed up as "nothing to act on
* this week" - that would hand an agent a fact about our sync health
* disguised as a fact about Congress.
*/
const verdict = limited.length === 0 ? emptyStateVerdict(getFreshness().checkedAt) : null;
const verdict = limited.length === 0 ? emptyStateVerdict(getFreshness()) : null;

return {
bills: limited,
Expand Down
58 changes: 49 additions & 9 deletions lib/freshness-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,56 @@ export function freshnessState(checkedAt: string, now: number = Date.now()): Fre

export type EmptyStateVerdict = 'quiet_week' | 'data_stale';

/** The three freshness signals `emptyStateVerdict` reads — structurally the
* same shape as lib/freshness.ts's `Freshness`, redeclared here (rather than
* imported) so this file stays free of any data import and safe to ship to
* a client bundle, per the header comment above. */
export interface FreshnessSignals {
/** Last successful nightly sync run — "did the job run at all". */
checkedAt: string;
/** Sync cursor high-water mark — "how far the backlog scan has actually
* processed" (data/sync-state.json's lastSync). */
completeThrough: string;
/** Newest `last_action_date` across the whole corpus — "is there anything
* current in the data at all", regardless of what the sync's own
* bookkeeping claims. */
newestAction: string;
}

/**
* The AE3 collapse rule as an importable primitive: an empty "Act now" band
* is a quiet week only while the data is fresh; both 'stale' and 'dead'
* collapse to data_stale — never assert quiet on dead data. This is the ONE
* copy of that rule: the site's empty band renders it (components/
* UrgencyEmptyState.tsx), and the future MCP `whats_moving` tool must import
* this same function rather than re-deriving the collapse inline — a second
* copy is the exact drift docs/solutions/stale-urgency-freeze.md closed for
* the urgency curve.
* reads as a genuine quiet_week only when EVERY freshness signal checks out.
* This is the ONE copy of that rule: the site's empty band renders it
* (components/UrgencyEmptyState.tsx), and lib/core/mcp.ts's `whatsMoving`
* imports this same function rather than re-deriving the collapse inline —
* a second copy is the exact drift docs/solutions/stale-urgency-freeze.md
* closed for the urgency curve.
*
* Two different thresholds, deliberately (2026-07-16, audit §5 item 4):
* - `checkedAt` (did the nightly job even run tonight) uses the tight
* FRESHNESS_CLAIM_WINDOW_DAYS/FRESHNESS_DEAD_WINDOW_DAYS pair via
* freshnessState — a "we checked recently" claim should go stale fast.
* - `completeThrough` (the sync cursor) and `newestAction` (the corpus's
* own newest activity) instead trip data_stale only past
* FRESHNESS_DEAD_WINDOW_DAYS, the wider of the two constants. Both are
* EXPECTED to lag `checkedAt` by real days under ordinary operation — the
* ascending backlog-scan cursor deliberately trails while it drains (see
* lib/freshness.ts's own doc comment and scripts/sync-bills.mjs's
* two-pass fetch design note) — so gating them on the tight claim window
* would make the site cry "data stale" every single night even when
* tonight's recent-first pass kept the actually-relevant content current.
* The wide dead window instead catches the failure mode this item exists
* for: a pipeline that runs every night, commits every night, and reports
* itself "fresh" via `checkedAt` alone while making no real forward
* progress for weeks (the bug this audit found — a 29-day-old cursor and
* a 29-day-old newest action, both silently passing as "fresh" under the
* old checkedAt-only check). Past three weeks with NOTHING new anywhere
* in the corpus, "quiet week" stops being a credible claim regardless of
* how recently the job merely executed.
*/
export function emptyStateVerdict(checkedAt: string, now: number = Date.now()): EmptyStateVerdict {
return freshnessState(checkedAt, now) === 'fresh' ? 'quiet_week' : 'data_stale';
export function emptyStateVerdict(signals: FreshnessSignals, now: number = Date.now()): EmptyStateVerdict {
if (freshnessState(signals.checkedAt, now) !== 'fresh') return 'data_stale';
if (freshnessAgeDays(signals.completeThrough, now) > FRESHNESS_DEAD_WINDOW_DAYS) return 'data_stale';
if (freshnessAgeDays(signals.newestAction, now) > FRESHNESS_DEAD_WINDOW_DAYS) return 'data_stale';
return 'quiet_week';
}
Loading
Loading