Fold in the A3 design review: 1. Composite revision = latest-index-date + index-content-hash + tracked symbol->CIK fingerprint, so a newly added ticker forces a run instead of being no_op'd/starved. No backfill sentinel — absence of a prior promoted run triggers backfill; source_max_date records the processed index date. 2. Full history needs the paginated submissions shards: filings.recent caps at 1000; older accessions (reportDate/acceptanceDateTime/isXBRL) live in filings.files[] shards (verified on Apple: recent=1000, one 1994-2015 shard). 3. Index<->Company-Facts consistency gate: they are separate SEC products that can lag; for every tracked isXBRL index accession, confirm it exists in Company Facts before promotion, else fail+retry (never record a null/partial snapshot). Non-XBRL amendments skipped with a recorded reason. 4. Immutable = insert-only (ON CONFLICT DO NOTHING); a differing re-fetch is a reported discrepancy, never a silent mutation / import_run_id replacement. Plus deterministic, mutually-exclusive cash/debt composition (aggregate-first; each source tag counted at most once). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15 KiB
A3 design — SEC fundamentals importer
Status: design APPROVED 2026-07-22 — three decisions signed off (daily-index
fetch, primary-period-only snapshots, full-history backfill) + four review
correctness fixes folded in (composite revision incl. universe fingerprint;
submissions pagination shards for full history; index↔Company-Facts consistency
gate; insert-only immutability with discrepancy reporting; deterministic cash/debt
composition). Ready to implement.
Companion to docs/dolt-integration-plan.md (workstream A, phase A3). Grounded in
live SEC data probes (Apple CIK 0000320193, company_tickers, submissions, daily-index).
Objective (unchanged from the plan)
Populate fundamental_snapshots (CIK-keyed, one immutable row per accession)
and tickers.cik/sic/sic_description from SEC data, as a SourceImporter
plugging into the A1 framework. Shadow only (A3): nothing reads snapshots until
A4; fundamental_data is untouched until the A5 parity gate. All new metrics
are display-only.
What the SEC data actually looks like (probed, not assumed)
data.sec.gov/api/xbrl/companyfacts/CIK##########.json — one JSON per issuer
(CIK) aggregating every period across every filing. Shape:
facts.us-gaap.<Concept>.units.<unit>[] = {start, end, val, fy, fp, form, filed, accn, frame}.
Ground-truth findings that drive the design:
fpis onlyQ1|Q2|Q3|FY— there is noQ4. Q4 must be derived.fy/fpare the filing's fiscal context, not each fact's period. Proven: Apple's FY2019 10-K carries a discrete Q3-FY2018 revenue fact (start 2018-07-01, end 2018-09-29, val 62.9B) taggedfp=FY— it's a comparative. Period identity lives in(start, end)+ the filing'sreportDate, never infp/fy. Selecting values byfpwould silently mix comparatives into the wrong period.- SEC provides both discrete 3-month facts and YTD-cumulative facts
(Apple Q2 FY26: YTD
254,940over 6mo and discrete111,184over 3mo;143,756 + 111,184 = 254,940). This confirms the stored-YTD schema: store cumulative YTD per filing, derive discretes/Q4/TTM at read time. - Instant facts (
dei:EntityCommonStockSharesOutstanding) end on the cover date (2026-04-17), which differs fromperiod_end(2026-03-28) → theshares_outstanding_datecolumn added in migration 026. - No conditional-GET support: the companyfacts endpoint returns no
ETagand noLast-Modified. AAPL's file is 3.75 MB. So ~505 unconditional fetches ≈ 0.5–1.5 GB per run — the plan's "conditional HTTP no-op" is impossible on this endpoint. This is the fact that decides the fetch strategy (below). submissions/CIK##########.jsonsuppliessic,sicDescription,fiscalYearEnd(e.g.0926), and per-accessionreportDate+acceptanceDateTime— the keys for period selection andaccepted_at.company_tickers.jsonuses dash tickers (BRK-B,BRK-A) and mapsGOOGL/GOOGto the samecik_str(1652044). The ticker→CIK join reuses the earnings importer'snormalise_symbol(dot→dash), so both sides match.
Decision 1 (APPROVED) — fetch strategy: EDGAR daily-index driven
Plan said bulk companyfacts.zip + ETag no-op. Reality: the data.sec.gov
endpoints expose no validators, and the bulk zip is multi-GB and changes ~daily
(all of EDGAR), so ETag would rarely match → near-daily multi-GB download to get
505 issuers. Per-CIK conditional fetch is impossible (finding 5). Per-CIK
unconditional is 0.5–1.5 GB every night.
Recommended: drive off the EDGAR daily-index (daily-index/YYYY/QTRn/ form.YYYYMMDD.idx — fixed-width Form/Company/CIK/Date/accession, ~3300 rows/day,
confirmed). Each run:
detect_revision→ a composite revision, not just the date:latest-index-date+ a hash of the index content processed this run + a fingerprint of the tracked symbol→CIK set. The CIK fingerprint is essential: a newly added ticker changes the revision and forces a run, so a new ticker is neverno_op'd away or starved waiting for its issuer to file. Equal composite revision →no_op.- No backfill sentinel. The absence of a prior promoted run is what triggers
the initial full-history backfill;
source_max_daterecords the processed index date each run. stage→ for each index date since the last processed one, parse the form index, keep rows whereform ∈ {10-K, 10-Q, 10-K/A, 10-Q/A}and CIK ∈ tracked set, then fetchcompanyfacts/CIK.jsonfor only those few issuers and extract their newly-reported period(s). Most nights this is a handful of issuers → near-zero transfer, respectful of SEC fair-access.- First run (backfill): no prior promoted run → fetch companyfacts for all tracked CIKs once (~1 GB one-time) and seed full history. Full history needs the paginated submissions shards — see "CIK resolution" below.
Why this over the alternatives: transfer scales with filings, not with all of
EDGAR or with the universe size every night; it restores the revision/no_op
model; and it's the lightest load on SEC. Cost: daily-index parsing + date
bookkeeping (store last-processed index date in data_import_runs /
settings). This deviates from the plan's "bulk zip" — requesting sign-off.
Decision 2 (APPROVED) — snapshot mapping: primary-period, YTD, immutable
One fundamental_snapshots row per accession, representing the filing's
primary current period only (not its comparatives):
- Select the primary period by
end == submissions.reportDate[accn](finding 2), not byfp/fy.fiscal_periodlabel comes from the filing's ownfp(a 10-Q's ownfpmatches its current quarter; a 10-K →FY);fiscal_year/period_start/period_endfrom the selected facts + submissions. - Duration facts → cumulative YTD. For each concept, pick the duration fact
with
accn == thisFiling,end == reportDate, andstart ≈ fiscal-year start(derived fromfiscalYearEnd), sanity-checked by span length (Q1≈3mo, Q2≈6mo, Q3≈9mo, FY≈12mo). If the YTD fact is absent, store null — never a discrete masquerading as cumulative (that would poison read-time differencing). - Balance-sheet instants → at
end == reportDate.shares_outstandingis the exception: takedei:EntityCommonStockSharesOutstandingfor that accession and store its ownendinshares_outstanding_date(cover date ≠ period_end). - Amendments: a real
10-K/A/10-Q/Ais a new accession → a new immutable row for the same(cik, fy, fp); readers pick the newest validaccepted_at. - Out of scope (stated, not silent): restatements that appear only as comparatives inside a later normal filing are not captured — only a real amendment updates a prior period. This narrows the plan's "newest accepted_at per period" to amendment-driven updates; a deliberate KISS boundary.
- Immutable means insert-only, not upsert.
promoteinserts new accession rows withON CONFLICT (accession) DO NOTHING. An accession never mutates: if a re-fetch reconstructs different values for an accession already stored, that is a discrepancy to report (intovalidation_json+ a system event), never a silent overwrite, and the originalimport_run_idis never replaced. (Ordinary updates arrive as a new amendment accession, which is a new row.)
Read-time derivation (constrains the importer; built in A4)
From the per-accession YTD rows, all at read time (newest accepted_at per
period), following the schema decision already in the plan:
- discrete quarter = YTD(Qn) − YTD(Qn−1); Q4 = FY − YTD(Q3).
- TTM = sum of the trailing four discrete quarters (e.g. TTM@Q2 = FY(prev) + YTD(Q2) − YTD(Q2 prev year)).
- YoY = period vs same period a year earlier.
- Hard rule the importer must enable: any missing period in a run → the derived
value is
null, never a partial number. So the importer must aim for complete consecutive quarter runs per issuer and report gaps.
Metric tag catalog (prioritized us-gaap tags + fallbacks)
Tagging is inconsistent across issuers (the plan's known risk). Each metric resolves through an ordered tag list; first present wins; unit-checked.
| Snapshot field | Primary tag | Fallbacks | Unit |
|---|---|---|---|
| revenue | RevenueFromContractWithCustomerExcludingAssessedTax |
Revenues, SalesRevenueNet |
USD |
| net_income | NetIncomeLoss |
— | USD |
| operating_income | OperatingIncomeLoss |
— | USD |
| diluted_eps | EarningsPerShareDiluted |
— | USD/shares |
| cfo | NetCashProvidedByUsedInOperatingActivities |
...ContinuingOperations |
USD |
| capex | PaymentsToAcquirePropertyPlantAndEquipment |
PaymentsToAcquireProductiveAssets |
USD |
| depreciation_amortization | DepreciationDepletionAndAmortization |
DepreciationAmortizationAndAccretionNet, DepreciationAndAmortization |
USD |
| cash_and_st_investments | see composition rule | — | USD |
| total_debt | see composition rule | — | USD |
| shares_outstanding | dei:EntityCommonStockSharesOutstanding |
— | shares |
Composite fields — deterministic, aggregate-first, no double counting. Each source tag contributes at most once:
cash_and_st_investments=CashAndCashEquivalentsAtCarryingValue+ short-term investments, where ST investments = the first present of [ShortTermInvestments,MarketableSecuritiesCurrent] — never both summed.total_debt= long-term component + short-term component, where- long-term = first present of [
LongTermDebt(the aggregate, already includes current + noncurrent portions), else (LongTermDebtNoncurrent+LongTermDebtCurrent)]; - short-term borrowings = first present of [
ShortTermBorrowings,CommercialPaper] (0 if neither). So the long-term aggregate and its components are mutually exclusive, and CP vs short-term-borrowings is a single pick — nothing is counted twice.
- long-term = first present of [
EBITDA (for net-debt/EBITDA) is derived at read time = operating_income + D&A. Concepts absent for an issuer → that field is null (display-only; no synthesis). The exact tag lists live as named constants, tunable without touching logic.
Fiscal-period identity
fiscalYearEnd (MMDD from submissions) anchors the fiscal-year start for YTD
span checks and Q4 derivation. Non-calendar fiscal years (Apple's Sept) are
handled because we key on (start, end) + reportDate, not calendar quarters.
fiscal_year/fiscal_period are stored from the filing's own fy/fp for its
primary period (safe — a filing's own context is correct for its current period).
CIK resolution & tickers backfill
- From
company_tickers.json:normalise_symbol(ticker) → cik_str. Settickers.cikfor each tracked ticker (multi-class share one CIK). - From
submissions/CIK.json:sic,sicDescription,fiscalYearEnd→tickers.sic/sic_description(+ fiscal anchor for YTD/Q4). - Submissions is paginated — full history needs the shards.
filings.recentholds only the latest 1000 filings (verified: Applerecent= 1000). Older accessions live infilings.files[]=[{name, filingFrom, filingTo, filingCount}](e.g.CIK0000320193-submissions-001.json, 1236 filings 1994–2015), each a bare object with the same parallel arrays includingreportDate,acceptanceDateTime, andisXBRL. The full-history backfill must follow everyfilings.files[].nameto obtain period identity +accepted_atisXBRLfor pre-1000 accessions. Incremental runs only needrecent.
- Refreshed by the SEC job; a newly added ticker self-resolves on its next run (the CIK fingerprint in the revision forces that run) — until then its snapshots are absent → metrics null, per the plan.
SourceImporter mapping (source = sec_facts)
detect_revision→ latest daily-index date (orbackfillsentinel on first run).stage→ resolve tracked CIKs; (incremental) parse indices since last date → tracked filers → fetch their companyfacts → build per-accession snapshot rows; (backfill) fetch all tracked companyfacts. In-memory staged set (KISS, per A1).validate(fail-closed) → tracked-universe coverage floor (issuers with ≥1 snapshot); unit/period sanity (YTD spans within tolerance; EPS in USD/shares); no duplicate accession; filings skipped for missing period identity are counted invalidation_json(carry-forward from A1 review); an unexpected companyfacts shape (missingfacts/units) stops promotion.- Index↔Company-Facts consistency gate (the daily index and Company Facts are
separate SEC products that can lag each other): for every tracked index
accession marked
isXBRL, confirm that accession actually appears in the fetched companyfacts before promotion. If any is missing → fail the run and retry later — do not advance the revision and do not record an incomplete/null snapshot for it. Non-XBRL amendments are skipped with a recorded reason invalidation_json. (The framework only stores the revision on a promoted run, so a failed consistency check naturally leaves the revision behind for retry.) promote→ insert snapshot rows (ON CONFLICT (accession) DO NOTHING; immutable — see Decision 2), stampedimport_run_id; refreshtickers.cik/sic/sic_description. A re-fetch that reconstructs different values for an existing accession is reported as a discrepancy, never a silent mutation. Non-destructive (append-only accessions) — no future-row deletion like earnings.
SEC fair-access (operational, per the plan's non-negotiable)
Identifying User-Agent with contact email on every request; well under 10 req/s
with spacing; exponential backoff on 429; 403 → alert and stop, never
retry-loop. New config: sec_user_agent, sec_request_spacing_seconds,
sec_max_retries. Keep only the last ~2 fetched artifacts on disk for debugging
(reproducibility is the normalized Postgres rows, per the plan).
Explicitly out of scope for A3
fundamental_datacutover (A5 parity gate) — snapshots only in A3.- The read-time derivation, API object, and panel (A4).
- Comparative-only restatements (Decision 2).
- Point-in-time backtest enforcement (
accepted_atstored, not yet enforced).
Decisions (signed off 2026-07-22)
- Fetch — EDGAR daily-index driven (Decision 1). Approved deviation from the plan's bulk zip.
- Snapshot mapping — primary-period-only per accession; comparative-only restatements out of scope (Decision 2). Approved.
- Backfill depth — seed full available history per issuer on first run (cheap to store; powers the quarter tape / multi-year YoY). Approved.