Fixes the federal mismatch class (boys' camp × NIH research center): - Peer precedent: federalPrecedent paginates USASpending (≤500 awards/ program) and name-matches every recipient against primary-ICP NH registry orgs (shared normalizeOrgNameForMatching, also used by the self-match gate). The 25-pt precedent tiers now key off program_state_peer_award_count — Dartmouth renewals and SBIR LLCs no longer grant precedent to community nonprofits. Raw count + peer- annotated award list stay as review evidence (peer badges, peers-first). - Mission-fit floor (12/30, grants_gov only): below it a match is stored with fit_viable=false and hidden from the pending queue, hero selection, and easy-win. Foundation-synthesized grants exempt (generic synopses). - Mission-fit judge live (judgeMatches, 06:15, 200/night best-first): JUDGE_MODEL reads the synopsis against the org profile with an explicit ignore-eligibility-breadth instruction; graded verdict with required citations; deterministic verdict→points map (27/18/8/0) sets missionFit, total, easy-win, and viability. Verdicts survive nightly re-scores via an upsert splice and re-enter the judge queue when the org profile is re-researched (org_profiles.updated_at). First sweep: 81/149 programs have NH history, only 6 have peer history; queue-head judging zeroes the research-mechanism garbage (mismatch) while surfacing genuine strong fits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@novelpad/outreach-worker
DBOS executor process for the HelmDocs grant-match outreach engine. Runs the
scheduled workflows that keep the outreach schema's grant catalog fresh —
nightly ingestion from upstream sources and an hourly expiry sweep — decoupled
from any HTTP-facing app in this monorepo (mirrors the workflow-worker split
in novelpad-desktop).
Boot order (load-bearing)
src/main.ts imports ./workflows/ingest-grants.js and
./workflows/expire-grants.js before calling DBOS.launch(). Each of
those modules calls DBOS.registerWorkflow + DBOS.registerScheduled at
module-evaluation time — DBOS only dispatches scheduled/queued jobs for
functions that were registered before launch, so importing them after launch
(or not at all) silently means the cron jobs never fire.
The db handle is then threaded into each workflow module via its
set*Deps injector (setIngestGrantsDeps / setExpireGrantsDeps), also
before launch — DBOS serializes scheduled-function arguments, so a Drizzle
client can't be passed through the scheduler call itself. This is the same
module-scope-registry pattern novelpad-desktop's workflow-worker uses for
setStartDeps.
1. import workflow modules → registers ingestGrants / expireGrants
2. build pg Pool + drizzle(db)
3. set*Deps({ db }) → populates each workflow's registry
4. DBOS.setConfig(...)
5. DBOS.launch() → scheduler starts firing
Workflows
ingestGrants(0 3 * * *, nightly) —fetchGrantsGov(stub; real implementation calls the Grants.gov Search2 API,POST https://api.grants.gov/v1/api/search2) →normalize→ upsert viaserverInsertGrantsfrom@novelpad/outreach-core/server. NH state postings and 990-PF extracts land as additional fetch+normalize steps later.expireGrants(0 * * * *, hourly) — marks grants whose close date has passed as closed viaserverExpireClosedGrants, so they drop out of the active match/scoring pool.
Both are registered as a DBOS workflow and a scheduled function referencing
the same function object (DBOS.registerWorkflow then DBOS.registerScheduled)
— see the doc comments in src/workflows/*.ts for why the dual registration
is required.
Scripts
yarn dev—node --env-file=.env --env-file-if-exists=.env.local --import tsx/esm ./src/main.tsyarn build— esbuild bundle tobuild/main.js(--packages=external)yarn start— run the built bundleyarn typecheck—tsc --noEmit
Environment
Copy .env.example to .env and fill in DATABASE_URL (required — main.ts
throws on boot without it) and GCP_SERVICE_ACCOUNT_KEY_PATH (used by
future ingestion/scoring steps that call Google-hosted APIs).