Upsert one advisor → Harper
This skill takes one advisor (a name plus optional hints) and produces a complete, sourced Advisor row in Harper, plus every related entity the public record supports: employment history, disclosures, sanctions, licenses, OBAs, registration applications, team memberships, transition events, article mentions, and field assertions.
It composes the three existing data paths instead of duplicating them:
┌──────────────────────── name (+ optional hints) ──────────────────────┐
│ │
▼ │
1. BrokerCheck name search ──► single CRD? ──no──► ask user / pick best ─┘
│ │
│ yes
▼ ▼
2. fetch_brokercheck.py --crd <CRD> → Advisor, EmploymentHistory,
(regulator-of-record fields) Disclosure, Sanction, License,
BrokerCheckSnapshot, Firm
3. AdvisorHub coverage walk → Article, ArticleAdvisorMention,
(extraction skill on each hit) Team, TransitionEvent,
RecruitingDealQuote,
TeamMetricSnapshot,
EmployerConcentration,
OutsideBusinessActivity,
FieldAssertion
4. Deep web research → bio fields (firstName, gender,
(firm bio, Barron's, press) birthYear, designations,
education, headshotUrl,
linkedinUrl, businessEmail/phone,
FieldAssertion provenance)
5. Verify + report
BrokerCheck wins on regulatory facts (disclosures, sanctions, U5,
exam history). AdvisorHub wins on narrative and non-regulatory
metrics (AUM, T-12, recruiting deal terms, team composition). Deep
research fills the soft fields neither structured source publishes
and must cite a quote in FieldAssertion for every fact it
contributes — otherwise it doesn't get written.
Steps to follow when this skill is invoked
0. Pre-flight
Make sure Harper is up:
npm run status
If it's stopped, ask the user whether to bootstrap (npm run bootstrap). Don't auto-bootstrap — that's a destructive-ish step
on a fresh box.
Required env for the BrokerCheck and extraction loaders:
HDB_TARGET_URL e.g. https://...harperfabric.com
HDB_ADMIN_USERNAME (or HARPER_ADMIN_USERNAME)
HDB_ADMIN_PASSWORD (or HARPER_ADMIN_PASSWORD)
If the user only gave you a local dev cluster, _harper.py falls
back to the operations Unix socket — no env needed.
1. Parse the user's input
Required:
- Legal name — the full name as it would appear on a U4 ("George J. Cairnes", "C. James Taylor"). Don't strip middle initials.
Optional disambiguators (use whichever the user provides):
- CRD — if given, skip step 2 entirely and jump to step 3.
- Current or last firm — narrows ambiguous name matches ("James Taylor at Wells Fargo").
- State — 2-letter, fed straight to BrokerCheck's
?state=filter. - Career-start year — used to break ties when BrokerCheck returns multiple same-name hits.
- First employer — same purpose; matches the
first_employernatural-key field used by the extraction loader.
Echo what you parsed back to the user in one line, e.g.
"upserting C. James Taylor (current firm: Wells Fargo Advisors, state: NY) — confirm or correct". Wait for confirmation only if
the parse is ambiguous; otherwise proceed.
2. Resolve the CRD (BrokerCheck name search)
Skip if the user already gave a CRD.
python3 scripts/fetch_brokercheck.py --search-name "<legal name>" --max 10 --dry-run
Read stderr for the candidate list. The search returns at most
--max _source blocks; what you want from each is:
ind_source_id— the CRDind_firstname,ind_lastname,ind_middle_nameind_other_names[]— AKAs / suffixesind_current_employments_firm_name[]andind_previous_employments_firm_name[]— career trailind_bc_scope—ACTIVE/InActive
Pick the single best match using the user's disambiguators, in this order:
- Firm overlap — the user's hinted firm appears in the candidate's current or previous employments.
- State — registered state matches.
- Career-start year —
daysInIndustry(from the detail record) puts the start within ±1 year of the hint. - First employer — first item of
ind_previous_employments_firm_name[]matches.
If exactly one candidate survives those filters, use its CRD. If
zero or more than one survives, stop and ask the user — print
the candidates as a numbered list with name, CRD, current firm,
state, scope. Don't guess on regulatory data. (The existing
--enrich mode bails on ambiguity for the same reason — see
scripts/fetch_brokercheck.py:160-204.)
3. Pull BrokerCheck (regulator-of-record)
python3 scripts/fetch_brokercheck.py --crd <CRD>
That single command writes:
Advisor(or upserts the existing row matched bycrd:<CRD>UUIDv5)BrokerCheckSnapshot(one row,fetchedAt= now, satisfies the FINRA ToU "as of " requirement)EmploymentHistoryrows, one per real tenure (BD + IA registrations at the same firm whose date ranges overlap or sit within ~90 days are folded by_dedupe_employmentsso the loader writes one row per job, not one row per scope — seetests/brokercheck_parse_test.py::test_dedupe_employments_*)Firmrows for any firm mentioned in employments (resolved byfirmIdfrom BrokerCheck)Disclosurerows withsourceType: "brokercheck"andsourceRef: "brokercheck:<CRD>:<docket>"Sanctionrows under each disclosureLicenserows for every exam instateExamCategory,productExamCategory,principalExamCategoryplus state-registration rows fromregisteredStates
Skip if --force isn't passed and the state file says we fetched
this CRD < 7 days ago. That's the right default for repeated
runs; pass --force when the user explicitly asks for a fresh
pull (e.g., "I just saw a new disclosure post"). Read
docs/brokercheck-spike.md §7 if you need to operate the scraper
in any non-default mode.
Note: BrokerCheck is the regulator-of-record. Do NOT have the
extraction step in §4 overwrite a Disclosure or Sanction that
came from BrokerCheck. The loader handles this correctly — both
write through the same (advisor, type, date, docket) UUID, and
on collision the resolver keeps the BrokerCheck-sourced row's
provenance fields. But it's worth knowing if you see seemingly-
"missing" extraction writes downstream.
4. Walk AdvisorHub coverage
Find every wpjson record that mentions the advisor by name:
grep -lFi "<legal name>" research/wpjson/*/post_*.json research/articles/*.wpjson.json 2>/dev/null
Also try the bare last name and any AKAs (ind_other_names[]
from the BrokerCheck search) — articles often use a middle-name
form ("James Taylor" vs. "C. James Taylor"). De-dupe by wpId.
For each matching wpId, follow the extract-advisorhub-articles skill's article loop verbatim:
python3 scripts/extract_helper.py show <wpId>— read it.Read
.claude/skills/extract-advisorhub-articles/schema-guide.mdandexamples.mdonce at the start of the batch (skip if you read them this session already).Write
research/extractions/<wpId>.jsonwith the structured extraction. Use the samelegal_nameyou used in step 2 — the resolver matches advisors by(legal_name, first_employer)plus employment-history overlap, and your BrokerCheck-sourced row already has both.After all extractions are written:
python3 scripts/load_extractions.pyResolver stats with
advisor_matched=N advisor_minted=0mean the loader correctly merged into the BrokerCheck-seeded row; anyadvisor_minted>0here is a bug in your extraction's natural_key — the legal name probably doesn't match the BrokerCheck row exactly. Fix and reload before moving on.
If the corpus has zero hits for the advisor's name, that's fine — say so in the report and move on. AdvisorHub doesn't cover every broker; absence isn't a failure.
If research/wpjson/ is empty, suggest /ingest-advisorhub
first to populate the corpus, then come back. Don't crawl from
inside this skill — that's a different scope and a different
politeness budget.
5. Deep research (the soft fields)
BrokerCheck and AdvisorHub between them don't publish:
- First name vs. preferred name (
Anthonyvs.Tony) - Gender (where self-disclosed)
- Birth year (rare in public sources; often only in regulatory filings we don't have access to)
- Headshot URL
- LinkedIn URL
- Public business email / phone
- Education (
institution,degree,field,graduationYear) - Designations (
CFP,CFA,CIMA, …) with granting body and earned date - Team affiliation — the firm's locator usually names the
practice ("The Ibis Group", "The Smith Wealth Group"). If the
bio reveals one, mint a
Team(deterministic id from_ids.team_id(name, current_firm)) plus aTeamMembership(_ids.team_membership_id(team_id, advisor_id)) and anArticleTeamMentionso the team chip appears on the advisor profile and the source-bio article. Use thecurrentFirmIdfrom the Advisor's openEmploymentHistoryrow — don't re-derive it from the firm name. - Outside-of-AdvisorHub press coverage and ranking-list appearances
For each missing or thin field on the Advisor row, run one or two targeted searches with the WebSearch tool. Good queries:
"<legal name>" "<current firm>" advisor bio"<legal name>" CFP CFA designation"<legal name>" Barron's "top advisors""<legal name>" site:linkedin.com/in"<legal name>" "<current firm>" team
Then WebFetch one or two of the highest-signal results — the firm's own bio page, a Barron's profile, a verifiable press release. Do not fetch LinkedIn — it's auth-walled and the scraper will return a login page; resolve LinkedIn URLs from search snippets only.
Advisor.preferredName convention — store the first-name
form only ("James" for "C. James Taylor", "Steven M." for
"Steven Manson Swann"), NOT the full display form. The UI
helper advisorDisplayName() concatenates preferredName + lastName, so writing the full name produces "Steven M. Swann
Swann" on every chip. The renderer is now defensive (it detects
when preferredName already ends with lastName and skips the
concat), but the convention still wins — keep new data clean.
For each fact you want to write, you must:
- Have the exact phrase you read it from. If you can't quote the source, don't write the fact.
- Write a
FieldAssertionrow with:targetTable∈Advisor|Designation|Education| …targetId— the resolved UUIDfieldNameassertedValue— JSON-encodedquotePhrase— the verbatim phraseconfidence—asserted/inferred/derivedarticleId— leave null for non-AdvisorHub provenance, but prefixquotePhrasewith the source URL so the row is still traceable:"[https://example.com/bio] graduated from Wharton in 2003".
Write this through Harper REST PUT /Advisor/<id> (and
/Designation/, /Education/, /FieldAssertion/) using the
same auth _brokercheck_load.py uses — re-import that module's
HarperREST rather than rolling your own client:
import sys, pathlib
sys.path.insert(0, "scripts")
from _brokercheck_load import HarperREST
from _ids import uid
rest = HarperREST(verbose=True)
rest.put("/Advisor/", {"id": advisor_id, "preferredName": "Tony", ...})
Use _ids.uid("Designation", advisor_id, "CFP") (or whatever
deterministic key the entity uses — check scripts/_ids.py) so
re-running this skill produces the same UUIDs and upserts cleanly.
If a field is genuinely unknown after a couple of searches,
leave it null. A null field is honest; a confabulated one
poisons every downstream join.
6. Verify and report
npm run verify
Then run a focused spot-check on the advisor you just touched:
python3 -c "
import sys, pathlib
sys.path.insert(0, 'scripts')
from _harper import sql
crd = '<CRD>'
adv = sql(f\"SELECT id, legalName, finraCrd, careerStatus FROM data.Advisor WHERE finraCrd = '{crd}'\")
if not adv: print('NOT FOUND'); sys.exit(1)
aid = adv[0]['id']
for t in ['EmploymentHistory','Disclosure','Sanction','License',
'TeamMembership','ArticleAdvisorMention','FieldAssertion',
'Designation','Education','OutsideBusinessActivity']:
col = 'targetId' if t == 'FieldAssertion' else 'advisorId'
n = sql(f\"SELECT COUNT(*) AS n FROM data.{t} WHERE {col} = '{aid}'\")[0]['n']
print(f' {t:30s} {n}')
"
Then report to the user (≤ 8 lines):
- Name + resolved CRD.
- BrokerCheck counts: employments, disclosures, sanctions, licenses.
- AdvisorHub counts: articles processed, mentions added.
- Deep-research counts: designations, education, soft-field FieldAssertions added.
- Anything skipped and why (no name match in articles; LinkedIn unfetchable; ambiguous CRD that the user resolved manually).
Idempotency at every layer
| Layer | Mechanism |
|---|---|
| BrokerCheck CRD lookup | Skips CRDs fetched in the last 7 days. --force to override. |
| BrokerCheck loader | Every entity ID is uuid5(NS, "<natural_key>"). Re-run = same UUIDs = upserts. Verified by tests/brokercheck_parse_test.py. |
| AdvisorHub extraction | extract_helper.py skips wpIds that already have a .json or a .loaded/<wpId>.json. To re-extract, move the file out of .loaded/. |
| AdvisorHub loader | Resolver: CRD > exact name + employment overlap > fuzzy > new. Once the BrokerCheck row exists, the resolver matches it by name + employment overlap, so re-runs never duplicate the advisor. |
| Deep research | Use _ids.uid(table, parent_id, key) so the same fact writes the same UUID. |
| FieldAssertion | Append-only; same (articleId, targetId, fieldName) tuple writes the same UUID via uid("FieldAssertion", articleId, targetId, fieldName) — confirm in scripts/_ids.py before the first write. |
What this skill does NOT do
- Crawl new AdvisorHub posts. That's
/ingest-advisorhub. Run it first ifresearch/wpjson/is empty for the advisor's name. - Cross a politeness budget. BrokerCheck is rate-limited at
≈ 0.7 req/sec by
_brokercheck.py. Don't add a parallel fetcher. WebSearch / WebFetch are similarly metered — keep deep research to ≤ 5 queries and ≤ 2 page fetches per advisor. - Bypass FINRA ToU. The
BrokerCheckSnapshot.fetchedAtyou just wrote must surface in the UI as "Source: FINRA BrokerCheck (as of ). Terms of use." That's theSourceAttributionatom underharper-app/web/design-system/atoms.js— already wired intoadvisor.js. If you change advisor-page rendering, keep that footer. - Resolve ambiguous BrokerCheck hits silently. Two CRDs with the same first/last name = stop and ask. Conflating them corrupts disclosures permanently.
- Reconcile BrokerCheck vs. AdvisorHub disagreements. When
the regulator and the trade press disagree on a fact (e.g., the
Cairnes fine —
$25,000in AdvisorHub,$2,500in BrokerCheck), keep both: BrokerCheck asDisclosure.fields, AdvisorHub asFieldAssertionwith the article quote.docs/brokercheck-spike.md§4 has the canonical example. - Write LinkedIn-scraped content. LinkedIn's ToU prohibits it. Use search snippets to capture the URL only.