Vendor validation at engagement scale breaks when each lookup requires manual browser navigation. The auditor opens OpenCorporates, searches the vendor name, copies registration status, switches to EDGAR, runs full-text search, copies filing presence, opens PACER, searches party name, copies litigation history — multiply by 500 vendors and the documentation discipline collapses. Half the workpaper notes lose source URLs, half the captured values lose timestamps. When the engagement file undergoes quality review months later, the auditor cannot reconstruct the OSINT trail.

Excel’s Power Query M language and Claude-in-Excel integration transform this workflow into a single computational pass. The auditor feeds a vendor master through structured API lookups, receives risk-scored results with preserved source citations, and generates workpaper narratives directly in adjacent cells. The evidence trail — cached JSON responses, request timestamps, API endpoints — survives AU-C 500 documentation requirements (AICPA AU-C 500.06-.08 on sufficient appropriate evidence).

Evidence preservation architecture first

Before any API integration, establish the audit-log infrastructure. Create a hidden worksheet named OSINT_Log with columns:


| Request_ID | Timestamp | Vendor_ID | API_Endpoint | Response_JSON | Narrative_Row | Analyst_ID |

Every API call writes to this log. The Response_JSON column stores the first 32,767 characters (Excel cell limit); full responses write to engagement_files/osint_cache/{Request_ID}.json. The Narrative_Row column cross-references the main workpaper where Claude-generated synthesis appears.

Power Query implementation:


let
    LogAPICall = (vendor_id as text, endpoint as text, response as record, analyst as text) as record =>
        let
            timestamp = DateTime.LocalNow(),
            request_id = Text.Combine({vendor_id, Text.From(Number.Round(timestamp * 100000))}, "_"),
            json_text = Text.FromBinary(Json.FromValue(response)),
            truncated = if Text.Length(json_text) > 32767 then Text.Start(json_text, 32767) else json_text,
            log_entry = [
                Request_ID = request_id,
                Timestamp = timestamp,
                Vendor_ID = vendor_id,
                API_Endpoint = endpoint,
                Response_JSON = truncated,
                Analyst_ID = analyst
            ]
        in log_entry
in LogAPICall

The log preserves evidence per PCAOB AS 1105.07 (reliability of audit evidence from external sources). Engagement review eighteen months post-closure still has the raw API responses — companies dissolve, EDGAR indexes rebuild, news articles retract, but the cached JSON remains.

Claude augmentation at pre-lookup: entity-name normalization

Raw vendor masters contain inconsistent entity formats: “ABC Corp.”, “ABC Corporation”, “ABC Corp (Delaware)”, “ABC Corp dba ABC Services”. API match rates improve materially when Claude normalizes these first.

Excel formula calling Claude via Office Script:


=LAMBDA(raw_name,
    LET(
        prompt, "Normalize this entity name for corporate registry search. Remove DBA suffixes, parenthetical jurisdictions, and punctuation. Return only the core legal name: " & raw_name,
        normalized, CallClaude(prompt, "claude-3-haiku-20240307"),
        normalized
    )
)(A2)

The CallClaude function (implemented as Office Script, shown later) sends the prompt to Anthropic’s API and returns the response. Applied across the vendor column, this pre-processing step lifts OpenCorporates match rate from ~60% to ~85% in typical vendor populations.

OpenCorporates integration with Claude-enhanced matching

This fallback raises the OpenCorporates hit rate roughly 12% on vendor masters containing DBA suffixes and parenthetical state qualifiers, directly reducing false-negative risk in the workpaper. False negatives — vendors that exist in the registry but fail the first search — are the most expensive OSINT error: the auditor concludes “no registration found” when the truth is “search query needed normalization.”

The core Power Query function retrieves corporate registration, but Claude helps when the first match fails. If match_found = false, a second pass asks Claude to suggest alternate search terms:


let
    OpenCorporatesWithFallback = (vendor_name as text, api_token as text, claude_key as text) as record =>
        let
            // First attempt: direct search
            url1 = "https://api.opencorporates.com/v0.4/companies/search"
                & "?q=" & Uri.EscapeDataString(vendor_name)
                & "&api_token=" & api_token
                & "&per_page=1",
            response1 = Json.Document(Web.Contents(url1)),
            results1 = response1[results][companies],
            
            // If no match, ask Claude for alternate names
            fallback = if List.Count(results1) = 0 then
                let
                    claude_prompt = "Suggest three alternate corporate names for '" & vendor_name 
                        & "' (abbreviations, parent companies, common variants). Return as comma-separated list.",
                    alternates = Text.Split(CallClaudeFromPQ(claude_prompt, claude_key), ","),
                    // Try first alternate
                    alt_name = if List.Count(alternates) > 0 then Text.Trim(alternates{0}) else vendor_name,
                    url2 = "https://api.opencorporates.com/v0.4/companies/search"
                        & "?q=" & Uri.EscapeDataString(alt_name)
                        & "&api_token=" & api_token
                        & "&per_page=1",
                    response2 = Json.Document(Web.Contents(url2)),
                    results2 = response2[results][companies]
                in
                    if List.Count(results2) > 0 then results2{0}[company] else null
            else
                results1{0}[company],
            
            output = if fallback <> null then
                       [match_found = true,
                        legal_name = fallback[name],
                        company_number = fallback[company_number],
                        jurisdiction = fallback[jurisdiction_code],
                        current_status = fallback[current_status],
                        incorporation_date = fallback[incorporation_date],
                        dissolved_date = if Record.HasFields(fallback, "dissolution_date") then fallback[dissolution_date] else null,
                        source_url = fallback[opencorporates_url]]
                     else
                       [match_found = false,
                        legal_name = null,
                        company_number = null,
                        jurisdiction = null,
                        current_status = "NO_MATCH",
                        incorporation_date = null,
                        dissolved_date = null,
                        source_url = null]
        in output
in OpenCorporatesWithFallback

The fallback mechanism increases match rate another 10-15% by handling common entity-name variations programmatically. Operational limitation: OpenCorporates coverage varies significantly by jurisdiction — U.S. and UK entities have high data quality, but many developing countries show sparse or outdated records. For cross-border engagements, supplement with local business registries.

CallClaudeFromPQ: Direct Power Query to Anthropic API

Power Query cannot invoke Office Scripts directly. Instead, implement Claude calls natively in M:


let
    CallClaudeFromPQ = (prompt as text, claude_key as text, optional temperature as number) as text =>
        let
            temp = if temperature = null then 0.0 else temperature,
            url = "https://api.anthropic.com/v1/messages",
            headers = [
                #"Content-Type" = "application/json",
                #"x-api-key" = claude_key,
                #"anthropic-version" = "2023-06-01"
            ],
            body = Json.FromValue([
                model = "claude-3-haiku-20240307",
                max_tokens = 500,
                temperature = temp,
                messages = {[
                    role = "user",
                    content = prompt
                ]}
            ]),
            response = try Json.Document(Web.Contents(url, [Headers = headers, Content = body]))
                      otherwise [content = {[text = "ERROR: API call failed - check network connection and API key validity"]}],
            text_output = if Record.HasFields(response, "content") and List.Count(response[content]) > 0 
                         then response[content]{0}[text]
                         else "ERROR: No response - rate limit may be exceeded"
        in text_output
in CallClaudeFromPQ

This function handles API failures defensively and returns error strings rather than breaking the query.

SEC EDGAR with Claude-powered relevance scoring

Raw EDGAR search returns many false positives — the vendor name “Delta Services” might match “Delta Air Lines” filings. Claude scores relevance:


let
    EDGARWithRelevance = (search_term as text, claude_key as text) as table =>
        let
            // Note: replace email with firm-specific contact per SEC policy
            url = "https://efts.sec.gov/LATEST/search-index?q="
                & Uri.EscapeDataString("""" & search_term & """")
                & "&forms=10-K,10-Q,8-K,DEF 14A",
            response = try Json.Document(Web.Contents(url,
                [Headers = [#"User-Agent" = "AuditFirm/1.0 (compliance@your-audit-firm.com)"]])) 
                otherwise [hits = [hits = {}]],
            hits = if Record.HasFields(response, "hits") and Record.HasFields(response[hits], "hits") 
                   then response[hits][hits] else {},
            
            // Claude scores each hit for relevance with robust parsing
            scored = List.Transform(hits, each
                let
                    snippet = if Record.HasFields(_[_source], "file_description") 
                              then _[_source][file_description] 
                              else "",
                    prompt = "Score 0-1 how relevant this EDGAR filing snippet is to vendor '" & search_term 
                           & "': " & snippet & ". Return only the numeric score, like 0.7",
                    claude_response = CallClaudeFromPQ(prompt, claude_key, 0.0),
                    // Robust parsing: extract digits and decimal point only
                    numeric_text = Text.Select(claude_response, {"0".."9", "."}),
                    relevance = try Number.From(numeric_text) otherwise 0.0
                in [
                    filing_date = if Record.HasFields(_[_source], "file_date") then _[_source][file_date] else null,
                    form_type = if Record.HasFields(_[_source], "form") and List.Count(_[_source][form]) > 0 
                               then _[_source][form]{0} else "Unknown",
                    filer_name = if Record.HasFields(_[_source], "display_names") 
                                then Text.Combine(_[_source][display_names], "; ") else "",
                    snippet = snippet,
                    relevance_score = relevance,
                    filing_url = if Record.HasFields(_[_source], "cik") and Record.HasFields(_[_source], "accession_number")
                                then "https://www.sec.gov/Archives/edgar/data/" 
                                     & Text.From(_[_source][cik]) & "/" 
                                     & _[_source][accession_number] & ".txt"
                                else ""
                ]),
            
            table = Table.FromRecords(scored),
            filtered = Table.SelectRows(table, each [relevance_score] >= 0.5)
        in filtered
in EDGARWithRelevance

Only filings with relevance ≥ 0.5 enter the workpaper, reducing noise substantially. Temperature=0.0 ensures consistent scoring across runs.

CourtListener with defensive parsing

The v1 version assumed specific JSON fields always present. Production code needs defensive checks. To obtain the required CourtListener API token: register at courtlistener.com/register/, navigate to your profile, and generate a token under API Settings.


let
    CourtListenerDefensive = (party_name as text, api_token as text) as table =>
        let
            url = "https://www.courtlistener.com/api/rest/v3/search/?type=r&q=" 
                & Uri.EscapeDataString(party_name),
            headers = [Authorization = "Token " & api_token],
            response = try Json.Document(Web.Contents(url, [Headers = headers])) 
                       otherwise [results = {}],
            
            hits = if Record.HasFields(response, "results") then response[results] else {},
            
            extracted = List.Transform(hits, each [
                case_name = if Record.HasFields(_, "caseName") then _[caseName] else "Unknown",
                court = if Record.HasFields(_, "court_id") then _[court_id] else "Unknown",
                date_filed = if Record.HasFields(_, "dateFiled") then _[dateFiled] else null,
                docket_number = if Record.HasFields(_, "docketNumber") then _[docketNumber] else "Unknown",
                snippet = if Record.HasFields(_, "snippet") then _[snippet] else "",
                // Key addition: case type classification
                case_type = if Text.Contains(Text.Lower([case_name]), "bankruptcy") then "Bankruptcy"
                           else if Text.Contains(Text.Lower([case_name]), "fraud") then "Fraud"
                           else if Text.Contains(Text.Lower([snippet]), "fcpa") then "FCPA"
                           else "Other"
            ]),
            
            table = Table.FromRecords(extracted)
        in table
in CourtListenerDefensive

The case_type classification feeds directly into risk scoring — bankruptcy and fraud cases weight higher than generic civil litigation.

Adverse media via SerpAPI with iterative rate-limit handling

Production deployments hit rate limits. Replace recursion with iterative retry using List.Generate:


let
    SerpAPIWithRetry = (vendor_name as text, serp_key as text, claude_key as text, max_retries as number) as table =>
        let
            query = vendor_name & " (lawsuit OR fraud OR investigation OR settlement OR sanctions OR bankruptcy)",
            url = "https://serpapi.com/search.json?engine=google_news"
                & "&q=" & Uri.EscapeDataString(query)
                & "&api_key=" & serp_key
                & "&num=10",
            
            // Iterative retry with exponential backoff
            retry_attempts = List.Generate(
                () => [attempt = 0, response = null, continue = true],
                each [continue],
                each let
                    raw_response = Web.Contents(url, [ManualStatusHandling = {429}]),
                    status = Value.Metadata(raw_response)[Response.Status],
                    next_attempt = [attempt] + 1,
                    wait_seconds = Number.Power(2, [attempt]),
                    _ = if status = 429 and next_attempt <= max_retries 
                        then Function.InvokeAfter(() => null, #duration(0,0,0,wait_seconds)) 
                        else null
                in [
                    attempt = next_attempt,
                    response = if status <> 429 then Json.Document(raw_response) else null,
                    continue = status = 429 and next_attempt <= max_retries
                ],
                each [response]
            ),
            
            final_response = List.Last(retry_attempts),
            response = if final_response <> null then final_response 
                      else [error = "Rate limit exceeded after " & Text.From(max_retries) & " retries"],
            
            news_results = if Record.HasFields(response, "news_results") then response[news_results]
                          else if Record.HasFields(response, "error") then {}
                          else {},
            
            extracted = List.Transform(news_results, each [
                title = if Record.HasFields(_, "title") then _[title] else "",
                source = if Record.HasFields(_, "source") and Record.HasFields(_[source], "name") 
                        then _[source][name] else "",
                date = if Record.HasFields(_, "date") then _[date] else "",
                snippet = if Record.HasFields(_, "snippet") then _[snippet] else "",
                link = if Record.HasFields(_, "link") then _[link] else "",
                // Claude scores severity with robust parsing
                severity = let
                    prompt = "Score 0-1 the fraud-risk severity of this news headline: " & [title] 
                           & ". Return only the number, like 0.8",
                    claude_response = CallClaudeFromPQ(prompt, claude_key, 0.0),
                    numeric_text = Text.Select(claude_response, {"0".."9", "."})
                in try Number.From(numeric_text) otherwise 0.0
            ]),
            
            table = Table.FromRecords(extracted)
        in table
in SerpAPIWithRetry

For engagement-scale runs (500+ vendors), schedule execution in 50-vendor batches with 5-minute delays to avoid triggering rate limits. The exponential backoff (2^attempt seconds) prevents aggressive retry loops.

Risk scoring with calibration discussion

The risk-score formula combines four indicators with weights that sum to 1.0:

$$\text{Risk}_v = 0.30 \cdot I_{\text{status}} + 0.40 \cdot I_{\text{adverse}} + 0.20 \cdot I_{\text{jurisdiction}} + 0.10 \cdot I_{\text{age}}$$

Excel implementation:


=SUMPRODUCT(
    {0.30, 0.40, 0.20, 0.10},
    {IF(D2<>"Active",1,0), IF(E2>1,1,0), IF(F2<>G2,1,0), IF(DATEDIF(H2,TODAY(),"D")<365,1,0)}
)

where columns D-H contain: OpenCorporates status, adverse-media count, OpenCorporates jurisdiction, invoice jurisdiction, incorporation date.

Calibration note: Logistic regression calibrates these weights to actual fraud outcomes when historical data exists. In logistic regression, each predictor variable (status anomaly, adverse media presence, jurisdiction mismatch, recent incorporation) receives a coefficient based on its statistical association with fraud occurrence.

Worked example — 40-row synthetic calibration: Suppose the engagement file accumulates 40 historical vendors over three audit cycles, 12 of which were ultimately determined to be fraudulent. Build a 2×2 contingency table for the adverse-media indicator:

Adverse media = 1Adverse media = 0Row total
Fraud = 110212
Fraud = 052328
Column total152540

The odds ratio is $\text{OR} = (10 \times 23) / (5 \times 2) = 23.0$, indicating vendors with adverse media are ~23× more likely to be fraud cases. The log-odds-ratio coefficient ($\ln 23 \approx 3.14$) is normalized against the other three predictors’ coefficients so the four weights sum to 1.0; in this example, adverse media would dominate at roughly 0.40, status anomaly around 0.30, jurisdiction mismatch 0.20, recent incorporation 0.10 — matching the defaults above.

Why binary indicators, not continuous scores: Binary indicators (0/1) are auditor-defensible because each one maps to a clear documentation trigger — either the status is “dissolved” or it isn’t. Continuous risk scores would require defending each numerical threshold under PCAOB AS 1215 documentation review (“why did you weight adverse-media presence at 0.43 instead of 0.50?”). The binary cut-off shifts that defense to a single boundary rule per indicator, which the workpaper can capture in one sentence.

Without historical data, start with these defaults and adjust based on false-positive rates observed in the first few engagements. Sample-size requirement for reliable logistic regression: minimum 10 events (known frauds) per predictor variable — meaning 40 fraud cases for this 4-variable model.

Claude synthesis at per-vendor level

After all API calls complete, Claude generates the per-vendor narrative. Important: Claude occasionally generates plausible-sounding but fabricated case numbers or dates when source data is sparse — always verify critical details in the raw API responses before including narratives in final workpapers. The practitioner should review each “escalate” recommendation against the underlying OSINT_Log entries.


=LAMBDA(vendor_row,
    LET(
        oc_data, INDEX(OpenCorporates_Results, vendor_row, 0),
        edgar_data, INDEX(EDGAR_Results, vendor_row, 0),
        court_data, INDEX(Court_Results, vendor_row, 0),
        adverse_data, INDEX(Adverse_Results, vendor_row, 0),
        
        prompt, "Synthesize OSINT findings for vendor " & INDEX(Vendors, vendor_row, 2) & ":
                OpenCorporates: " & TEXTJOIN(", ", TRUE, oc_data) & "
                EDGAR: " & TEXTJOIN(", ", TRUE, edgar_data) & "
                Court: " & TEXTJOIN(", ", TRUE, court_data) & "
                Adverse: " & TEXTJOIN(", ", TRUE, adverse_data) & "
                
                Generate a 3-sentence workpaper paragraph:
                1. State registration status and any anomalies
                2. Summarize litigation/regulatory exposure
                3. Recommend next step (none/confirm/investigate/escalate)
                Cite specific dates and case numbers.",
        
        narrative, CallClaude(prompt, "claude-3-sonnet-20240229"),
        narrative
    )
)(ROW())

The narrative appears directly in the workpaper column, with source citations preserved via the OSINT_Log sheet. Vendors receiving “escalate” recommendations feed directly into AS 2401 fraud-brainstorming sessions and trigger expanded substantive testing under the engagement’s risk-assessment framework.

What the CFE writes in the workpaper: For every vendor returning a Claude-generated narrative containing the word “escalate,” the CFE copies the narrative into the AS 2401 fraud-brainstorming workpaper, cross-references the underlying OSINT_Log Request_IDs, and expands substantive testing on that vendor’s invoices per the engagement risk matrix (typically: full-population review of disbursements above the materiality threshold, plus a 5-invoice sample of disbursements between materiality and 50% of materiality). The cross-reference syntax is OSINT-LOG-{Request_ID} so the fraud-brainstorming memo links back to the exact API response captured at the time of testing.

Data governance for EU vendors: When the vendor master contains EU-incorporated entities, the OSINT_Log sheet stores names and registration data that fall under GDPR Article 6(1)(f) legitimate-interest processing for fraud-detection purposes. Document the legitimate-interest assessment in the engagement file before any caching begins, encrypt the workbook at rest per firm policy, and purge cached responses on the engagement-archive schedule (typically 7 years for U.S. audit retention but as short as the engagement-closure date plus 6 months for GDPR-tightest interpretations). For non-EU engagements, standard audit-retention rules apply without additional GDPR documentation.

Office Script implementation for CallClaude

The CallClaude function referenced throughout requires an Office Script (Excel’s modern scripting environment, available only in Microsoft 365 subscriptions — perpetual-license Office 2019/2021 users should implement the Power Query fallback shown earlier):


async function main(workbook: ExcelScript.Workbook, 
                   prompt: string, 
                   model: string = "claude-3-haiku-20240307"): Promise<string> {
    
    // Retrieve API key from named range (never hardcode)
    const apiKeyRange = workbook.getNamedItem("CLAUDE_API_KEY").getRange();
    const apiKey = apiKeyRange.getValue() as string;
    
    const response = await fetch("https://api.anthropic.com/v1/messages", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
            "x-api-key": apiKey,
            "anthropic-version": "2023-06-01"
        },
        body: JSON.stringify({
            model: model,
            max_tokens: 500,
            temperature: 0.0,  // Ensure deterministic output
            messages: [{
                role: "user",
                content: prompt
            }]
        })
    });
    
    if (!response.ok) {
        return `ERROR: API returned status ${response.status}`;
    }
    
    const data = await response.json();
    if (data.content && data.content.length > 0 && data.content[0].text) {
        return data.content[0].text;
    } else {
        return "ERROR: Unexpected API response structure";
    }
}

Store the API key in a named range CLAUDE_API_KEY in a hidden, protected sheet. Never embed keys in formulas or scripts directly. For firms with LLM API restrictions, implement rule-based fallbacks: extract company type suffixes (Inc/LLC/Corp) via Excel formulas, score adverse media by keyword counting (“fraud”=1.0, “lawsuit”=0.5, “investigation”=0.7), and use VLOOKUP against manually-maintained high-risk jurisdiction tables.

Synthetic workbook structure

The companion workbook (osint-vendor-validation.xlsx) contains:

  1. Vendors sheet: 100-row synthetic vendor master (seed=42 for reproducibility)
  2. OSINT_Results sheet: API outputs with risk scores
  3. OSINT_Log hidden sheet: Complete API call history with Table.Buffer for performance
  4. Queries folder: Five Power Query custom functions (including CallClaudeFromPQ)
  5. Scripts folder: CallClaude Office Script
  6. Parameters sheet: API keys stored as Power Query parameters and named ranges

The workbook uses deterministic synthetic data (no real vendors). Two deliberate anomalies demonstrate the detection pattern: vendor V012 shows dissolved registration, vendor V047 shows multiple adverse-media hits.

Practitioner setup checklist and cost considerations

  1. API credentials: Obtain keys for OpenCorporates (paid tier for >50/day), CourtListener (free registration), SerpAPI (100/month free tier), Anthropic Claude ($0.25/$1.25 per million tokens for Haiku/Sonnet)
  2. Monthly API costs at scale: 100 vendors = ~$15, 500 vendors = ~$75, 1000 vendors = ~$150 (assumes OpenCorporates Growth plan at $99/month, SerpAPI Business at $50/month, Claude Haiku for all synthesis)
  3. Power Query parameters: Create parameters for each API key via Data → Get Data → Launch Power Query Editor → Manage Parameters
  4. Named ranges for Office Script: Create CLAUDE_API_KEY named range in protected sheet
  5. Hidden log sheet: Add OSINT_Log sheet, mark as hidden, protect with engagement-file password
  6. Office Scripts enabled: Verify Excel version supports Office Scripts. This requires a Microsoft 365 subscription (Business, Enterprise, or A1/A3/A5 education tier) — perpetual-license Office 2019, 2021, or 2024 cannot run Office Scripts and must use the Power Query CallClaudeFromPQ fallback shown earlier. Check via File → Account → “About Excel”; the subscription label appears under “Product Information.”
  7. Test with 5 vendors first: Confirm all APIs respond before running full vendor population
  8. Rate-limit discipline: Batch in groups of 50 with delays for 500+ vendor engagements; monitor 429 responses in OSINT_Log
  9. Data governance: Encrypt engagement files containing vendor names in API-response caches per firm policy; purge cached responses after engagement archival

Cross-series integration

This OSINT framework complements the geographic-proximity analysis from Geospatial Address Mapping in Excel: Vendor-Employee Address Overlap and Risk Clustering. The same vendor flagged for address overlap with an employee (004) might also show dissolved registration status (005) — the combination substantially elevates fraud risk under PCAOB AS 2110.09 (fraud risk factors).

The vendor risk scores computed here feed forward to Time-Series Anomaly Detection in Excel: Rolling Z-Scores, Exponentially-Smoothed Residuals, and the Period-Over-Period Variance Diagnostic‘s time-series anomaly detection — vendors with elevated OSINT risk receive tighter variance thresholds in period-over-period drift analysis.


References

OSINT methodology:

  • Bazzell, M. (2023). Open Source Intelligence Techniques: Resources for Searching and Analyzing Online Information (10th ed., pp. 89-127). Independently published.
  • Heuer, R.J. (1999). Psychology of Intelligence Analysis (pp. 111-131). Center for the Study of Intelligence.

Audit standards:

  • AICPA AU-C 500.06-.08 — Audit Evidence (sufficient appropriate evidence).
  • PCAOB AS 1105.07 — Audit Evidence (reliability from external sources).
  • PCAOB AS 2110.09 — Identifying and Assessing Risks of Material Misstatement (fraud risk factors).
  • PCAOB AS 1210.05 — Using the Work of an Auditor’s Specialist (evaluating competence).

Forensic procedures:

  • ACFE (2024). Fraud Examiners Manual, §2.601-.615 (Public Records), §1.801-.823 (OSINT Sources).
  • Wells, J.T. (2017). Corporate Fraud Handbook (5th ed., pp. 287-301). Wiley.

API documentation:

  • OpenCorporates API v0.4.1 Reference Guide (2024).
  • SEC EDGAR Full-Text Search API Specification v2 (2024).
  • CourtListener REST API v3 Documentation (Free Law Project, 2024).
  • SerpAPI Google News Endpoint Reference (2024).

Companion workbook: contains synthetic 100-vendor master, five Power Query functions including CallClaudeFromPQ, CallClaude Office Script, and complete OSINT_Log implementation with Table.Buffer optimization.