← Corpus / augment-it / spec
Funder Content Corpus Workflow — what the system has to do, ranked by quality bar, with no implementation prescribed
Tonight's session went sideways because the implementation got ahead of an explicit shared statement of goals. This spec captures the goals — what the operator wants the funder-content augmentation workflow to actually produce, in what shape, with what quality bar — without prescribing how. Any future implementation choices (refactoring the pack, evolving Content Reader, swapping connectors, adding curation surfaces) must measure themselves against these goals. The deeper failure mode tonight was a class of fixes that filtered yesterday's bad data tighter at display time instead of producing today's good data via a fresh fire; the goals here are written so a future agent can resist that pull. Treat the prior specs ([[Response-Reviewer-Shell-and-Content-Reader-Mode]], [[Flow-for-Bundles-Packs]], [[Entity-Pulse-Bundle]]) as compatible siblings — this spec sits at the level of 'what we want,' those describe pieces of the 'how.'
- Path
- specs/Funder-Content-Corpus-Workflow.md
- Authors
- Michael Staton
- Augmented with
- Claude Code on Claude Opus 4.7 (1M context)
- Tags
- Spec · Augment-It · Funder-Corpus · Per-Record-Augmentation · Quality-Bar · Goals · Reach-Edu
Funder Content Corpus Workflow
What we are actually trying to produce
For each client (today: reach-edu; later: Laerdal and others), a private repository of markdown files containing content from each funder’s official update channels — their news index, blog, grants announcements, press center, newsroom, magazine, stories archive. The corpus exists to support two downstream uses:
- Cross-funder fundraising-strategy synthesis (analyze priorities, styles, programs across many funders at once).
- Per-funder outreach customization (cite their own recent work, match their language, demonstrate alignment).
Per-client privacy isolation lives in the per-client repo
([[Per-Client-Privacy-and-the-Path-Off-Local]] §Path D). The
corpus shape lands as
clients/<client>/corpus/<funder-slug>/<YYYY-MM-DD>_<title-slug>.md
with the frontmatter contract from
[[Response-Reviewer-Shell-and-Content-Reader-Mode]] §Corpus markdown
shape.
This spec captures the WORKFLOW that produces those files — what data flows through the system, what each step has to honor, and where the operator has authority.
The hard rules — quality bar that any implementation must meet
Stated bluntly so they can’t be diluted by patches:
Rule 1 — The funder’s own domain is the only valid domain
“Literally anything not on the funder’s domain is invalid and spam” — operator, 2026-06-05.
A piece of content goes into the funder’s corpus ONLY when its URL’s
hostname matches the funder’s official hostname (or a subdomain of
it). No exceptions for “well-known publishers,” no exceptions for
press wires, no exceptions for Google News, no exceptions for
third-party news sites that mention the funder. If aecf.org is
Annie E. Casey’s domain, the only legal corpus URLs for AECF are
aecf.org and *.aecf.org. Period.
This rule applies at three layers and any one of them is sufficient to enforce it:
- The pack must not emit responses on off-domain URLs.
- The preview server (content-ingest or equivalent) must not fetch off-domain URLs.
- The review UI must not display off-domain URLs as previewable.
A future implementation MAY choose to enforce at only one layer for simplicity, but enforcement at the pack layer is preferred because it stops the bad data at the source instead of letting it accumulate in the store and then filtering it on display.
Rule 1 addendum — manual additions ride Rule 5, not Rule 1
Added 2026-06-08 alongside the Content Reader manual-add affordance.
Rule 1 binds pack outputs (content_ingest.preview and the pack
layer itself). When the operator pastes a URL from their own search
into the per-card ”+ add URL manually” affordance, the URL is fetched
via Jina, written to corpus, and the corpus markdown’s frontmatter
records the URL as-is. Same-host is not enforced for manual adds.
Why this is consistent rather than a loophole:
- Rule 5 (“the operator decides what enters the corpus, per item”) is the authority spine of this workflow. Rule 1 exists because pack discovery is broad and noisy and needs a hard filter to keep the per-funder corpus shape coherent. The operator pasting a URL is a different signal — they’ve already done the discovery, the filter isn’t theirs to fight.
- The URL is logged correctly in the corpus frontmatter
(
exact_url:,pack_id: manual), so downstream cross-funder analysis can re-impose domain-based filters from the data itself if it wants to. Nothing is lost. - The UI surfaces an “off-domain” chip on the preview as information, not as a block.
The pack layer (official-blog-pack, future content packs) keeps
enforcing Rule 1 on emitted candidates — that’s where the rule lives
and where it earns its keep.
Rule 2 — Navigation pages are not content
Pagination (/page/N/), taxonomy (/category/X/, /tag/X/,
/topic/X/, /author/X/), archives (/archive/, /YYYY/,
/YYYY/MM/), feeds (/feed/, /rss/, /atom.xml), and section
landing pages (/news/, /press/, /news-and-insights/press-center)
are NOT articles and never belong in the corpus. They might appear
as INDEX URLs (the operator-curated starting points the pack walks
INTO), but they themselves are not corpus content.
The Hewlett /latest-updates/page/2/, /page/3/, /page/190/
case and the Schusterman /news-and-insights/press-center,
/resource-hub, /press-center/press-kit case both violated this
rule. Any implementation must reject these shapes BEFORE the
operator sees them.
Rule 3 — Operator curation is authoritative
When the operator has curated official_updates_index_urls on a
row via the records-surface per-record connector flow, those URLs
are the INDEXES the pack walks into. The pack must not “improve”
them by re-discovering, must not “augment” them with SerpApi
backfill, must not “broaden” them with path-guessing. The operator
has already done the discovery; the pack’s job is the next step
(harvest article links from inside the curated indexes).
The pack MAY fall through to discovery (SerpApi + homepage scrape +
path-guess) only when official_updates_index_urls is empty for the
row. That fallback path is for rows the operator hasn’t curated yet.
Rule 4 — The row’s url field must be the funder’s correct domain
If row.fields.url is wrong, every downstream step is poisoned —
the pack searches the wrong domain, returns wrong-entity results,
and the operator wastes time triaging garbage. Examples observed
2026-06-05:
- Griffin Catalyst:
url = "https://www.citadel.com"(Ken Griffin’s hedge fund, not the foundation) - Google.org:
url = "https://www.google.com"(Google’s main search engine, not google.org) - Howard Schultz Foundation, Lumina, ~20 others:
url = "unknown" - Colorado Succeeds:
urlfield contains LLM prose with the URL buried in a paragraph
The system must surface these rows to the operator for repair and must NOT silently use them for pack fires. See [[Incorrect-Base-URLs-on-a-Real-Minority-of-Records]] for the audit; that issue is the systemic statement.
Rule 5 — The operator decides what enters the corpus, per item
No bulk “ingest everything” step. The operator reviews each discovered article, optionally edits the title and adds tags, and explicitly clicks “add to corpus” for each item that belongs. Items not added simply aren’t; their absence from the corpus is the absence of a decision. Re-triggering preview can re-surface them.
Rule 6 — Already-in-corpus items don’t reappear in preview lists
Once an item has been added to the corpus (its markdown file exists), it must NOT show up in the operator’s preview list on subsequent reviews. The operator has already decided; making them re-decide is friction. A future “show items already in corpus” affordance is fine, but it must be off by default.
Rule 7 — All records remain visible, even those with zero content
The Content Reader must show every record in the active record set, including records where the pack found nothing or hasn’t been fired. Records with zero content responses get a clear “no content yet” affordance (with a suggestion to fire the pack from Pack Runner, or to fix the row’s URL if that’s why nothing was found). Showing only records with existing content responses is misleading — it hides the work that’s left to do.
Rule 8 — Old responses must not be confused with new ones
When a pack is re-fired with corrected URLs or new filters, the operator must be able to tell that the NEW responses are different from the OLD responses still in the store. Either: replace on re-fire, or version the responses with a fire_id, or surface “last fire was YYYY-MM-DD” so the operator can scope to recent. Without this distinction, every “improvement” looks like it had no effect because the OLD data dominates what’s visible.
The workflow — what flows through the system
These are the steps in order. Implementation details (which service, which UI, which capability) are deliberately omitted.
Step 1 — Ingest a record set
CSV/XLSX upload produces a record set. Each row carries an entity name and (ideally) a primary URL. Many rows will have wrong or missing URLs from the upload data — that’s normal and must be repaired before pack fires can succeed.
Step 2 — Repair / curate per-row data
The operator works through the record set in the records-surface flow:
- Confirms/corrects each row’s
urlfield (Rule 4). - Fires per-record connectors (Firecrawl scan, Firecrawl + agent, SerpApi) to discover the right index URLs.
- Picks the correct discovered URLs into
official_updates_index_urls. Multiple indexes per row are allowed and expected (a funder may have/news/,/blog/,/grants/, all valid; the operator accepts each).
This step is the OPERATOR’S CURATION. Every downstream step must respect it.
Step 3 — Promote, when curation is sufficiently complete
The operator promotes the record set, producing a new generation. Promote must preserve the operator’s URL fixes (Rule 4) and curated indexes — not silently overwrite them.
Step 4 — Fire content packs against the promoted set
A pack-fire (entity-blog bundle, single pack official-blog-pack)
walks each row’s curated indexes and emits article-URL responses
into the response store. Per-row outcomes:
- Curated path (operator has
official_updates_index_urls): pack scrapes each curated index, picks article-shaped links (Rule 2 rejects), returns one response per article. The response’s URL is on the funder’s own domain (Rule 1 enforced at emit time). - Discovery path (operator hasn’t curated yet):
pack uses SerpApi
site:<row_url_host>+ homepage scrape + path-guess to find candidate indexes, then walks the same way. Off-domain results from SerpApi backfill are rejected (Rule 1). - No URL path (
row.fields.urlis broken): pack does NOT fire against this row. Response is a clear “url missing or invalid, repair via records-surface” outcome.
Step 5 — Review per record, preview content, curate to corpus
The operator opens the review surface (today: Content Reader inside Response Reviewer; future: a dedicated corpus-browser may emerge). For each record, two add paths share the same preview-then-add UX:
5a — Pack-discovered URLs (the default path). Click “Preview
content” — server fetches the body of each pack-discovered article URL
via Jina, returns title + excerpt + fetched-at. Per item: optionally
edit the title, optionally add tags (free-text for v0.0.1; controlled
vocabulary later), click ”+ add to corpus” — writes a markdown file
with the spec’d frontmatter to
clients/<client>/corpus/<funder-slug>/. Already-in-corpus items
don’t appear in the preview list (Rule 6). Records with zero pack
responses still appear (Rule 7) with a “fire from Pack Runner”
suggestion.
5b — Manual URL (the operator-found path, added 2026-06-08). Every
card has a collapsed ”+ add URL manually” affordance. The operator
expands it, pastes a URL they found via their own Google search, and
clicks Preview. The server fetches via Jina and returns the same
preview shape; the operator edits title + tags and clicks ”+ add to
corpus” exactly as for 5a. The corpus markdown carries pack_id: manual and a synthetic response_id: manual-<ts>-<rand>. Rule 1
(same-host) is not enforced — see the Rule 1 addendum above. The UI
surfaces an “off-domain” chip in the preview as information, not as a
block.
Step 6 — Commit the corpus and use it
The operator commits the per-client repo’s corpus directory. The markdown files become the input to downstream analysis (cross-funder strategy LLM, per-funder outreach customization).
What this spec is NOT trying to decide
- Which service owns the pack-fire orchestration.
- Whether Content Reader is a tab in Response Reviewer or its own microfrontend.
- Whether the pack walks index pages with Firecrawl or with raw fetch + cheerio.
- Whether Jina is the only content-extractor; equivalents (BeautifulSoup-server, Mercury Parser, etc.) may substitute.
- How dedup across re-fires happens at the data-model level.
These are implementation choices. The goals above bind any implementation choice, but don’t dictate one.
What we tried tonight and why it failed (so the next attempt doesn’t repeat it)
Tonight’s session built:
- A new
content-ingestservice with Jina-pull + corpus.add + corpus.list_for_record capabilities. - A Content Reader view mode inside
apps/response-reviewer. - A same-host filter at three layers (pack, content-ingest, UI).
- A navigation-pattern filter at three layers.
- A subdomain-aware host match.
- A
curated_index_urlsplumbed through pack input. - 25 row.update fixes to broken url fields (Griffin Catalyst, Howard Schultz, etc.).
- A hide-in-corpus filter on previews.
It produced a UI that showed 20 records of 96, mostly with hub-page junk from yesterday’s pack fire, and the operator’s reaction was correctly “this is fucked up.” The failure mode was:
- Filtered yesterday’s bad data tighter at display time instead of producing today’s good data. Without re-firing the pack, every “improvement” was a display-layer narrowing of the same stale responses. The operator saw the same wrong stuff with slightly different framing each iteration.
- Didn’t enforce Rule 3 until very late. The pack ignored
operator-curated
official_updates_index_urlsuntil the final patch of the session. Earlier patches were repairing symptoms of that single architectural miss. - Didn’t enforce Rule 4 systematically. Tonight’s bulk
url-repair found 23 broken rows on v8 and force-corrected most;
but several more (Google.org’s
google.com, perhaps others) slipped through the heuristic. A new audit pass with eyes-on per row is the only reliable fix. - Didn’t enforce Rule 7. Content Reader’s “show only records with content responses” filter hid the 74 not-found rows the operator most needed to see (so they could fire packs against them or fix their URLs).
- Didn’t enforce Rule 8. The operator had no way to tell that what they were seeing was yesterday’s data vs. today’s; the UI looked the same after every code change.
What to do on restart
The operator’s call: revert tonight’s uncommitted code, restart from these goals.
- Re-read this spec end-to-end.
- Audit
row.fields.urlfor every row in v8 — eyes on, not a heuristic. Fix any that are wrong. - Re-fire
entity-blog(1 pack:official-blog-pack) on v8 against rows that have either (a) a correcturlAND curatedofficial_updates_index_urls, OR (b) the operator’s explicit “try this row” trigger. - Open whatever review surface ships against the new data.
- If quality is still poor, the pack’s
pickPostLinks/looksLikePostneed deeper work — likely RSS-first parsing for sites with feeds, and walk-two-levels-deep for sites whose curated indexes are themselves indexes of articles.
The data-fixes from tonight’s row.update sweep (25 entities
across 5 generations) are NOT part of “code reverts” — those were
genuine repairs of broken state that pre-dated tonight’s session.
Whether to keep them is the operator’s call. The repaired Griffin
Catalyst, Howard Schultz, Lumina, and others have correct url
fields now and that’s actually useful.
See also
- [[Response-Reviewer-Shell-and-Content-Reader-Mode]] — the implementation spec written earlier today. The shell + mode pattern still applies; tonight’s work just didn’t execute it cleanly. Treat as the next-most-detailed sibling.
- [[Flow-for-Bundles-Packs]] §“The connectors” — the
records-surface per-record fire path where
official_updates_index_urlsgets populated. Step 2 of the workflow above. - [[Entity-Pulse-Bundle]] — the pack family this workflow uses. This spec narrows the focus to JUST the content-shaped subset (official-blog-pack) and explicitly defers the press-release + social-posts packs (which need different review modes and ingest patterns, per Rule 1’s strict domain enforcement and Rule 2’s no-navigation enforcement).
- [[Per-Client-Privacy-and-the-Path-Off-Local]] §Path D — where the corpus markdown lives.
- [[OfficialPulse-URLs-Appear-as-Junk-in-Promoted-Versions]] — the audit that found 1,109 responses with 3 accepts. Confirms Rule 1 is real and was being violated at scale.
- [[Incorrect-Base-URLs-on-a-Real-Minority-of-Records]] — the audit that quantified Rule 4’s violation rate at 24% of rows.
- [[Augment-Transformations-Not-Reliably-Persisting]] — the earlier audit that named the pre-June-3 records-surface bug responsible for some of Rule 4’s broken state.