The normative text the data documentation is written against, rendered from its source, CONTRACT.md.
callbase — Data Contract (v1)
The stable interface between callbase and every downstream project. This is what clients are allowed to depend on. Anything not described here (the
raw/zone, internal staging, file counts) is an implementation detail that may change. Breaking changes bumpschema_versionand are announced via the manifest.Read §0 first. Parts of this document are live and verified; parts have never been implemented. The difference matters, and is not otherwise obvious from the prose.
Base URL placeholder below:
https://data.quartercall.com(a public R2/GCS bucket on a custom domain). Companion:collect/DESIGN.md.
0. Status — what is promised, and what is still design intent
Not every clause below has met live data. This contract was drafted before the collector existed, so it is part verified promise and part intention. Distinguishing the two is the point of this section: a clause that has never been implemented is a sketch of an interface, not an interface.
Clauses here that have already failed on contact with the real service:
| Clause | As specified | Live reality | Resolution |
|---|---|---|---|
| §2 manifest | assumed curated/, index/submissions.parquet, catalog/ all exist | none of them did | unbuilt fields omitted, never written as null; quarters: [] means "nothing published" |
§3 account_value type | DECIMAL(20,2) | some codes hold text, dates or n/a | NULL in accounts, the text in curated/accounts_text (D1c); the Dropdowns turned out to be 0/1, numeric (§3, read back 2026-09-22) |
§3 account_value scale | DECIMAL(20,2) | 15 codes hold ratios; A998 Net Worth Ratio = 0.1472 | widened to DECIMAL(24,8) |
The last one is the instructive case. DECIMAL(20,2) would render A998 as 0.15 — and A998 is the
figure that determines a credit union's net-worth classification, so that is not a rounding error,
it is a wrong answer. Nothing in the specification was obviously wrong; it was simply written by
someone reasoning about what call report data ought to look like.
The same lesson arrived from outside, too: NCUA's own published OpenAPI declares the discovery and
universe endpoints as returning bare JSON arrays, while the live service returns a {"CreditUnion":[…]} envelope. A client generated from that schema deserializes to zero rows —
silently, and indistinguishably from "nothing new". Interfaces written ahead of payloads are wrong in
both directions.
Status of each section
| Section | Status |
|---|---|
§1 index/manifest.json | live — published every run |
§1 curated/ | live — first published 2026-08-24 (16 cycles); 30 cycles, 2019Q1–2026Q2, since 2026-09-20, readable at https://data.quartercall.com/curated/. The series begins at 2019Q1 by decision (collect/docs/PROGRESS.md §1) |
§1 catalog/ | designed, not built |
§1 external/ | live — external/fred/, written by connect fred and put by connect upload (collect/connect/), weekly by connect-fred.yml. First upload 2026-10-15 (run 38689371758, last_run 2026-10-15T05:54:56Z): sources.json read back, 36 series, none failed; every series file matches the catalogue's file_sha256 and row_count, 138,631 rows, 1943-01-01 to 2026-10-13 [measured 2026-10-15, meta spike 2026-10-14-fred-series-ids@31924ca]. Not in index/manifest.json (PC5) |
| §1 source catalogue declarations and leased publication | live for FRED — post-#209 owner run 38787016488 on merged 3fe467b, 2026-10-15. Ordinary URLs read back after the 300-second cache window: all 36 files match catalogue hashes and row counts (138,631 rows); all 35 unchanged observation hashes retain fetched_at and exact file bytes; the lease is released [measured 2026-10-15, platform spike 2026-10-15-platform-209-live-acceptance@0eee07a]. Cross-source races and interrupted dataset publication have synthetic tests; they were not exercised by this single-source live run |
§1 external/fhfa/ | live — owner run 38794963814 on merged a51f93f, 2026-10-15. Ordinary public URLs read back after the 300-second cache window: raw source and panel hashes verified, 7,242 actual rows, 50 states plus DC, 1991Q1–2026Q2; canonical content digest and DuckDB v1.5.6 / Parquet V1 / Snappy receipt verified; all three catalogues agree on one artifact and one event. FRED's source entry, catalogue and all 36 files (138,631 rows) remain unchanged; FHFA lease released [measured 2026-10-15, platform spike 2026-10-15-fhfa-live-acceptance@4bf955a]. Sequential publication is verified; cross-source races and interrupted publication remain synthetic acceptance. Engine state join and PC5 manifest integration remain separate work |
§2 core manifest fields (schema_version, generated_at, collector_run_id, freshness) | live |
§2 quarters[] counts (quarter, cycle_date, status, submission_count, cu_count) | live — populated from the collector's holdings |
§2 quarters[].status = dormant, quarters[].dormant_since | live 2026-09-20 — published and read back. Set by callbase curate from the catalog on every run: 18 quarters open, 14 dormant (2018Q3–2021Q4), each dormant one with a dormant_since and no open one with it |
§2 quarters[].updated_at | redefined 2026-09-20; merged 2026-09-21 (#133); not yet live — when the quarter's files were last built, set by callbase curate and left alone by a sweep. The first scheduled run carrying it (2026-09-22) lost its runner before reconcile finished, so the live manifest still shows every quarter re-stamped by the last sweep [read 2026-09-22: one distinct value across 30 quarters]. Live at the next successful curate |
§3 ingested_at, and when a cycle's files are rewritten | changed 2026-09-20, live the same day — read back: a run with nothing new left all 90 cycle objects with the ETag and Last-Modified they had. callbase curate used to rebuild every cycle every night, so ingested_at said when the last run was and every object's Last-Modified moved nightly. It now leaves a cycle alone unless its filings, the collector's code or its outputs changed, so an untouched cycle keeps its bytes, its ETag and its ingested_at. Once a week every cycle is rebuilt as an audit: accounts and accounts_text then get a new ingested_at and ETag with identical rows, while profiles and the index, which carry no run clock, stay byte-identical |
§2 quarters[].accounts_path, accounts_text_path, profiles_path | live on all 30 quarters [read 2026-09-22]. accounts_path is still the discriminator: its presence, not a non-empty quarters, is what means "readable" |
§2 index | live — {submissions_path, first_seen_from, rows} [read 2026-09-22: 147,476 rows, first_seen_from 2026-09-05T02:01:41Z] |
§2 latest_bulk_cycle, catalog | unverified — omitted until real |
§2 quarters[].sealed, quarters[].authorized | removed 2026-09-20. Both were published as false on every quarter. sealed restated status, and promised an immutability no quarter can have while amendments reach cycles of any age; status now carries stability alone, as open or dormant. authorized was untrue of every cycle whose bulk file had shipped, and is one frontier, not a per-quarter fact: quarter <= freshness.latest_bulk_cycle, once that is tracked. Neither was ever marked live here, so schema_version stays 1.0 |
§2 inventory | live — operational; universe.record_count is every CU record NCUA holds, not a denominator |
§2 quorum_policies, quarters[].quorum | unverified — added 2026-09-26 at schema_version 1.1 (collect/docs/design/filing-quorum.md §12). Written by callbase curate; checked on every PR against the fixture store, not the live domain: contracts/data.quartercall.com/docs/tests/test_fixture_world.py holds the two launch policies, the object on 2026Q2 and not on 2026Q1, and each verdict recomputed from the published integers. Not yet read back from https://data.quartercall.com |
§3 index/quorum/quarter=YYYYQn/data.parquet | unverified — added 2026-09-26 with quarters[].quorum, which points at it. Its columns are held to what the collector writes by test_fixture_schema.py on every PR; not yet read back from the live domain |
§3 curated/accounts | live — 165,669,629 rows across 30 cycles [measured 2026-09-21]; schema verified by reading the published files back, and held to what the collector writes by contracts/data.quartercall.com/docs/tests/test_fixture_schema.py on every PR |
§3 index/submissions.parquet | live — written by callbase curate from the catalog every run (E-001); [read 2026-09-22] 147,476 rows, status current 146,742 / superseded 734, the §4 recipe returns rows from the live domain. first_seen is NULL for the filings held before the catalog stamped them (index.first_seen_from says where stamps begin); validation_date and content_hash are filled for those by callbase reconcile from the archived bodies (2026-09-13), so they are NULL only for a body that could not be read |
§3 curated/accounts_text/ | live — every value that did not parse as a number, as filed, per quarter, for the winning filing (D1c); [measured 2026-09-21] 413,265 rows, 30 cycles, five codes (§3's note, read back 2026-09-22) |
§3 cuid on curated/accounts and index/submissions.parquet | live 2026-09-20 — published, read back, and listed in §3's schema tables (Which key) — NCUA's entity id, the key that survives a charter conversion (stage-2-curated-zone.md D11, D14; agreed 2026-09-19). A trailing nullable INTEGER, so nothing positional moved. Read back 2026-09-20: present on 148,014 of 148,016 index rows — NULL only for the two filings whose payload has no Profile block; 5,480 entity ids over 5,586 charters; no cuid on two current rows in a cycle; no charter with two. One institution is one cuid across a conversion: NORTHERN is 5163 as charter 68696 through 2026Q1 and as 24989 from 2026Q2 |
§3 curated/profiles/ | live 2026-09-20 — published, read back, and specified in §3 — the payload's Profile block, one row per winning filing: eight identity and observation columns from the catalog, then NCUA's 179 fields typed as its OpenAPI document declares them (stage-2-curated-zone.md D10, D10b). Read back 2026-09-20: 147,280 rows, 32 cycles, 187 columns; identity agrees with index/submissions.parquet on every row; observed_at is never NULL (123,551 rows from first_seen, 23,729 from the archive object's write time); no cu_number, cuid or rssd_number appears twice in a cycle. The three things a reader must know, which §3 now says in full: cu_number is the filing's, and profile_charter_number is a signal, never a key — they differ on 1,676 rows, which are 110 charter conversions; assets_prior_cycle and members_prior_cycle are the previous cycle's except on 2026Q2, the newest cycle when measured, where they have been the cycle's own [measured 2026-09-22] — for assets read A010; and before 2022Q4 those two and peer_group are NULL (78,530 of 78,530 rows), because NCUA serves a placeholder there. It does not cover curated/accounts: two winning filings have no Profile block (charter 21782 at 2020Q3, charter 566 at 2026Q2), quarters[].profiles_missing says so per cycle, so join LEFT. credit_union_type_id is the cycle's, not today's: it moved for 99 of 99 institutions that changed charter, which is what makes IN ('FCU','FISCU') the federally-insured filter across a conversion (2026Q1: 2,672 FCU, 1,578 FISCU, 86 NFICU). Measured 2026-09-20, per field, in collect/reference/callbase/profile-fields.csv: 136 of the 179 fields carry the cycle's value. Two do not: profile_charter_number and credit_union_name are what NCUA held when callbase fetched the filing, on every cycle's row — of 305 charters that renamed between 2019Q1 and 2026Q1, the 2019Q1 row carries the 2026 name for 303. A name read from this table is the credit union's name now, and a rename cannot be seen here. 34 fields never differ between cycles and are unverified: stable, or as-of-fetch |
| §4 recipes | run 2026-09-20 against https://data.quartercall.com — every recipe executed as written. The recipes §4 carried before that had never been run and did not work: they globbed over HTTPS, which DuckDB cannot do. catalog/init.sql is still not built |
| §4 query recipes | live and credential-free — read_parquet over https://data.quartercall.com/... was run against the published files, including partition pruning. The submissions recipe's path exists (the index is live); the catalog/ recipe's does not. The block's "32 files" predates the series floor: the manifest lists 30 |
| §1 public read URL | live — https://data.quartercall.com on callbase-prod, published as base_url. CORS is on [measured 2026-09-21]. Any User-Agent reads it — curl, wget, python-requests, none at all — since a WAF skip rule for the host [measured 2026-09-22]; collect/OPERATIONS.md §3.1 |
| §5 semantics | mixed; see the notes on individual bullets. Deduped & newest-wins is enforced and verified: zero duplicate (quarter, cu_number, account_code) keys and no quarter with two versions of one credit union, across all 30 cycles [measured 2026-09-22, 165,669,629 rows] |
§2 digests (quarters[].accounts_sha256, accounts_bytes and their text and profiles kin, index.submissions_sha256, submissions_bytes, quarters[].quorum.detail_sha256, detail_bytes) | unverified — added 2026-10-28 at schema_version 1.2 (Fields added 2026-10-28). Written by callbase curate from each staged file before its put; checked on every PR against the fixture store, where every digest matches its file. Not yet read back from https://data.quartercall.com |
§2 freshness.last_curate_run, last_curate_run_id, inventory.catalog_at, quarters[].counts_at | unverified — added 2026-10-28 with the digests. Written by the collector; not yet read back from the live domain |
| §6 versioning | in force since 2026-08-24 — see below. First minor bump, to 1.1, made 2026-09-26 and effective from the first manifest published with it. Second, to 1.2, made 2026-10-28, from the first manifest callbase curate publishes with every path digested |
The rule going forward: no clause is promoted from unverified to promised until it has been implemented and checked against a real payload. Two of the three failures above would have been caught by a single afternoon with 30 live reports, which is exactly how they eventually were.
That rule is also what the §6 row means. The corrections in the table above cost nothing because nothing was published; the first published curated file is the moment that stops being true, and from then on a retype is a major version bump rather than an edit to this document.
That moment has passed.
curated/accountswas first published on 2026-08-24, 16 cycles and 5,431,189 rows; it has been 30 cycles since 2026-09-20. The schema in §3 is now a promise rather than a proposal: changing a column's type, name or nullability is a major version bump, and the freedom this document had to correct itself in place — which it used three times, forDECIMAL(20,2), for the nine non-numeric codes, and for the ratio scale — is spent.
1. Layout
{base}/
index/
manifest.json # START HERE — describes everything
submissions.parquet # master index: one row per submission held, all quarters
quorum/quarter=YYYYQn/data.parquet # filing quorum: one row per expected filer (§3)
curated/
accounts/quarter=YYYYQn/data.parquet # the facts (long/EAV), partitioned by quarter
accounts_text/quarter=YYYYQn/data.parquet # every value that is not a number, as filed (D1c)
profiles/quarter=YYYYQn/data.parquet # the credit-union dimension: NCUA's Profile form, one row per filing
submissions/quarter=YYYYQn/data.parquet # per-quarter header rows — not built
catalog/
init.sql # ready-made DuckDB views over the above
catalog.duckdb # (optional) same views, pre-attached
external/
sources.json # one entry per source: last_run, failed[], catalogue, catalogue_kind
fred/
series.parquet # the catalogue, one row per series: FRED's metadata, source, licence, source_url, fetched_at, sha256, row_count, file_sha256
series=<ID>/data.parquet # date DATE, value DOUBLE (NULL for FRED's ".")
fhfa/ # live; state purchase-only HPI (§0)
datasets.parquet # one current artifact per dataset; commit point
artifacts.parquet # all retained artifacts
publications.parquet # ordered events: each time an artifact became current
source/dataset=hpi_po_state/sha256=<source_sha256>/hpi_po_state.txt
artifact/dataset=hpi_po_state/sha256=<file_sha256>/data.parquet external/ holds public series from other sources, each row attributed to its source, the URL it
was fetched from (without a key), when, and the hash of what was fetched: it is source-attributed,
not as filed. FRED's files are replaced in place when the source revises them. A FRED catalogue
row's sha256 is the hash of the observations as fetched; its file_sha256, of the exact bytes
of the series' data.parquet. FHFA retains both raw bytes and panels at immutable paths; only
its catalogues and source index entry are replaced.
sources.json holds one entry per source, keyed by the name of its directory under external/.
An entry names its catalogue (catalogue, a path relative to external/, inside that directory) and
the catalogue's kind (catalogue_kind), which says how its rows name their files:
catalogue_kind | catalogue | The files its rows name |
|---|---|---|
fred-series-v1 | fred/series.parquet, one row per series | series=<series_id>/data.parquet, its bytes file_sha256; series_id matches ^[A-Z0-9]+$. FRED's entry also carries series_count |
datasets-v1 | <source>/datasets.parquet, one row per dataset naming its current artifact, beside artifacts.parquet, every artifact published | each row's path (bytes file_sha256) and source_path (bytes source_sha256), relative to external/<source>/; content-addressed, so never rewritten. FHFA is specified below; publication status is in §0 |
A source's upload replaces its own entry and keeps every other as the bucket holds it, so an entry
written before catalogue and catalogue_kind existed may lack them. Both fields are now live for
FRED (§0); verification refuses an entry that lacks them or declares an unknown kind.
FHFA state purchase-only HPI (datasets-v1)
Dataset hpi_po_state reads https://www.fhfa.gov/hpi/download/quarterly_datasets/hpi_po_state.txt: traditional, purchase-only,
quarterly, State. Both seasonal adjustments and the whole history from 1991Q1 are retained.
Local source acceptance: 7,242 rows, 50 states and DC, 1991Q1–2026Q2; identical bytes on a second
run changed no artifact [measured 2026-10-15, platform spike 2026-10-15-fhfa-hpi-connector@8e591d1].
There is no stable panel URL: read datasets.parquet, then the exact path it names, never a glob. sources.json's fhfa entry has catalogue: fhfa/datasets.parquet, catalogue_kind: datasets-v1, dataset_count: 1, last_run (successful source check), and failed: [].
datasets.parquet columns, in order:
| Column | Type | Meaning |
|---|---|---|
dataset_id | VARCHAR | hpi_po_state |
hpi_type, hpi_flavor, frequency, level | VARCHAR | traditional, purchase-only, quarterly, State |
units | VARCHAR | index, 1991Q1=100 |
source, licence, notice | VARCHAR | FHFA; public domain, HPI® trademark, cite “Source: FHFA HPI®”; “This product uses FHFA data but is neither endorsed nor certified by FHFA.” |
source_url | VARCHAR | The fixed source URL above |
source_sha256, transform | VARCHAR, INTEGER | Raw bytes' SHA-256 and positive connector transform version; together with dataset_id, the artifact identity |
path, source_path | VARCHAR | Panel and raw source paths, relative to external/fhfa/, matching the layout above |
file_sha256 | VARCHAR | Exact panel bytes' SHA-256; integrity and publication pin |
content_sha256 | VARCHAR | Canonical typed panel content's SHA-256, independent of Parquet encoding |
writer_duckdb_version, writer_options | VARCHAR | Actual DuckDB engine version (SELECT version()) and JSON object with explicit compression and parquet_version COPY options |
row_count | BIGINT | Panel row count |
first_quarter, last_quarter | VARCHAR | Observation coverage (YYYYQn), not vintage identifiers |
fetched_at, published_at | VARCHAR | UTC ISO-8601: source bytes first fetched; artifact selected as current by the prepared publication |
artifacts.parquet holds dataset_id followed by the columns from source_sha256 through fetched_at, one row per (dataset_id, source_sha256, transform). publications.parquet holds dataset_id, source_sha256, transform, published_at in event order. A → B → A has two artifacts
and three events. A source hash plus transform equal to the current artifact changes neither its
timestamps nor its catalogues; only the successful source-check clock advances. The transform is
bumped when identical source bytes would produce different panel schema or values, guarded by a
canonical-content golden fixture. A writer upgrade or encoding change alone does not bump it.
Existing artifacts retain their exact bytes and original writer receipt; unchanged catalogues retain
their bytes too, including on seeded runners. New panels currently use Parquet V1 with Snappy.
The uploader compares every inventory field with the current selection and preserves every field
of previously published artifact records, including these source-specific receipts.
content_sha256 hashes UTF-8 JSON (no whitespace, no ASCII escaping), with this array structure: ["fhfa-content-v1", [[column, type], ...], [[value, ...], ...]]. The schema and values use the panel's
column order below; rows sort by (state, quarter). Each non-null DOUBLE becomes its exact Python float.hex() string; strings remain strings and NULL becomes JSON null. The digest includes
warning text and missingness. The connector verifies written and retained panels against it. file_sha256 still identifies exact immutable bytes; a content digest never substitutes for that
integrity check. Different source bytes may have identical typed content (for example decimal
formatting changed): their source provenance and selection events remain distinct, with the shared
content digest making that distinction visible. These fields were finalized before the first FHFA
publication; there is no adapter for earlier local preparations.
The panel is sorted and unique by (state, quarter). Its columns are state VARCHAR (50 USPS states
and DC), quarter VARCHAR (YYYYQn), index_nsa DOUBLE, index_sa DOUBLE, warning VARCHAR.
Empty or whitespace-only index cells remain NULL; absent observations remain absent. Non-blank
index cells must match [0-9]+\.[0-9]+ exactly; precision is not restricted to two decimal places. The exact known warning is
“Note that this state has fewer than 15,000 transactions over the latest ten years”. No fill-forward
or geographic interpolation occurs. The engine's planned SA join is labelledheadquarters state,
not lending geography* and cites file_sha256; that consumer is not part of this connector.
Unknown headers, states or warning text, duplicate keys, invalid years/quarters, non-positive or non-finite or non-decimal non-blank indexes, a missing or changed 1991Q1 = 100 baseline in either adjustment for any state, disappearing published keys, and retreating coverage refuse the run. The existing current dataset stays selected. Paths must match their dataset and hash, with no leading slash, traversal, backslash or scheme; they are validated before use.
Publication order is source objects, panel objects, artifacts.parquet, datasets.parquet, publications.parquet, then the source index entry. The current catalogue only names complete
objects. A retry first recovers any missing current event using the current catalogue's original published_at, then appends a new selection. Immutable objects are never overwritten. Mutable
catalogues use the 300-second cache policy; source and panel objects may be cached indefinitely.
Clients read index/, curated/ and external/. Do not depend on raw/ or any path not listed here.
2. manifest.json (the entry point)
Small, cacheable, regenerated every run. Clients poll it to learn what exists and how fresh it is.
{
"schema_version": "1.2",
"generated_at": "2026-09-27T06:50:02Z",
"collector_run_id": "2026-09-27T0649Z-a1b2",
"base_url": "https://data.quartercall.com",
"freshness": {
"service_watermark": "2026-09-26",
"latest_bulk_cycle": "2025Q4",
"last_successful_run": "2026-09-27T06:50:02Z",
"last_curate_run": "2026-09-27T06:50:02Z",
"last_curate_run_id": "2026-09-27T0649Z-a1b2"
},
"quarters": [
{ "quarter": "2026Q2", "cycle_date": "2026-06-30", "status": "open",
"submission_count": 4318, "cu_count": 4300, "counts_at": "2026-09-27T06:49:02Z",
"accounts_path": "curated/accounts/quarter=2026Q2/",
"accounts_sha256": "4eb80630638758eb6030ae3cff395b67baaea46c39dde6694316b301c13d0935", "accounts_bytes": 48211907,
"quorum": {
"measurement": "filing-quorum/2",
"measured_at": "2026-09-27T06:49:37Z",
"due_date": "2026-07-30",
"expected_basis": "prior_cycle",
"weights_from": "2026Q1",
"expected": 4336, "filed": 4299, "filed_unweighted": 3, "filed_by_charter": 0,
"outstanding_count": 37, "excused": 0,
"assets_expected": 2412345678901, "assets_filed": 2404000000000,
"largest_outstanding_assets": 7489000000,
"coverage": { "by_count": 0.9914667896678966789667896679,
"by_assets": 0.9965404299334073599670900767,
"by_assets_ceiling": 1, "ceiling_basis": "excuses",
"largest_outstanding_share": 0.0031044472877584391509124532 },
"verdicts": {
"floor/1": { "status": "reached" },
"default/1": { "status": "reached" }
},
"materiality": 0.0025,
"outstanding_material": [
{ "cuid": 5536, "cu_number": 5536, "name": "EXAMPLE CREDIT UNION",
"last_known_assets": 7489000000, "share": 0.0031044472877584391509124532 } ],
"detail_path": "index/quorum/quarter=2026Q2/data.parquet",
"detail_sha256": "29f7fe418a1c889a2ade798cd04cc3a8add195dce5bc2922abc4edf6434ec23e", "detail_bytes": 412377
},
"updated_at": "2026-09-27T06:49:37Z" },
{ "quarter": "2026Q1", "cycle_date": "2026-03-31", "status": "open",
"submission_count": 4123, "cu_count": 4031, "counts_at": "2026-09-27T06:49:02Z",
"accounts_path": "curated/accounts/quarter=2026Q1/",
"accounts_sha256": "267dce97df8bbd4a7c8b39f39617c6c085a4242477c6e1b0725f02a5fae7e16b", "accounts_bytes": 47530112,
"updated_at": "2026-06-26T13:32:10Z" },
{ "quarter": "2022Q4", "cycle_date": "2022-12-31", "status": "dormant",
"dormant_since": "2023-11-02", "submission_count": 4912, "cu_count": 4760,
"counts_at": "2026-09-27T06:49:02Z",
"accounts_path": "curated/accounts/quarter=2022Q4/",
"accounts_sha256": "727fe9886802ebaa0928b31e0fb0134e9fe6fe95efb5ca1362a3772a2fb0dc78", "accounts_bytes": 52004418,
"updated_at": "2026-06-26T13:32:10Z" }
],
"index": { "submissions_path": "index/submissions.parquet",
"submissions_sha256": "21e945ad5e3b81adf2ac82114f7e701ca858a81c9b678ea7d23f95927b71d424", "submissions_bytes": 6120994 },
"catalog": { "init_sql": "catalog/init.sql", "duckdb": "catalog/catalog.duckdb" },
"quorum_policies": {
"floor/1": {
"owner": "callbase", "measurement": "filing-quorum/2",
"min_by_count_bp": 9000, "min_by_assets_bp": 9000, "max_outstanding_share_bp": 100,
"rule": "reached when filed*10000 >= min_by_count_bp*expected and assets_filed*10000 >= min_by_assets_bp*assets_expected and largest_outstanding_assets*10000 <= max_outstanding_share_bp*assets_expected; indeterminate when expected_basis is partial; below otherwise",
"approved": "2026-09-26",
"evidence": "the owner's decision; spike 2026-09-26-floor-and-giants@81e60fa" },
"default/1": {
"owner": "callseries", "measurement": "filing-quorum/2",
"min_by_count_bp": 9800, "min_by_assets_bp": 9800, "max_outstanding_share_bp": 50,
"rule": "reached when filed*10000 >= min_by_count_bp*expected and assets_filed*10000 >= min_by_assets_bp*assets_expected and largest_outstanding_assets*10000 <= max_outstanding_share_bp*assets_expected; indeterminate when expected_basis is partial; below otherwise",
"approved": "2026-09-26",
"evidence": "spike 2026-09-26-quorum-policy@9924aa0; spike 2026-09-26-floor-and-giants@81e60fa" }
},
"inventory": {
"catalog_at": "2026-09-27T06:49:02Z",
"unattributed_filings": 0,
"universe": { "as_of": "2026-06-25", "record_count": 23725 }
}
} The 2026Q2 verdicts carry no reached_at: the quarter was already reached when first measured,
and a time callbase did not observe is never written (Fields added 2026-09-26 below).
Bootstrapping state (current). The omit-rather-than-null rule still governs: fields describing unbuilt things are omitted entirely rather than written as
null, because a manifest advertisingindex/submissions.parquetbefore it exists would send every client to a 404.index,base_urlandlatest_bulk_cycleare still absent for exactly that reason. Clients should test for a key's presence rather than assume it.
quarters: []would mean nothing has been collected yet — "no data available", not an error. As of 2026-08-24 it is not empty: 16 cycles are published.⚠️
accounts_pathis what means "readable", not a non-emptyquarters. A quarter entry appears as soon as the collector holds filings for a cycle, which can precede the curated file for it — that was the state for every cycle until 2026-08-24. Test foraccounts_path; the distinction outlives the bootstrap, because a newly collected cycle is held before it is built. (www/status/docs/status-page.md§7 has the reasoning.)
Field contract:
schema_version— semver-ish; a major bump = breaking change to schemas/layout.quarters[]— the series: every cycle from 2019Q1, newest first, and nothing older. The archive holds part of two earlier cycles (2018Q3, 2018Q4) that NCUA will not serve in full; they are kept and are not listed here, not built intocurated/, and not inindex/submissions.parquet. Iteratingquarters[]therefore never meets a cycle that cannot be completed. (Until 2026-09-20 both were listed, with a few hundred filers each.)quarters[].status— the manifest's one statement about stability: how likely a quarter's files are to move.open— filings for the cycle are still arriving; its files may change between runs.dormant— NCUA has validated no filing for the cycle in more than 365 days; its files change only if a late amendment does arrive, and that makes the quarteropenagain. 365 was measured, not chosen: over 30 cycles it is the point at which a quiet cycle stops being woken (once, against 7 times at 180 days), and filings have been seen up to 1,456 days after a cycle ends and not beyond (collect/DESIGN.md§9).
Neither is a promise. NCUA accepts an amendment to a cycle of any age, so no quarter is ever final, and
dormantdescribes what has been observed rather than what will happen. A client must treat anystatusit does not recognise asopen.statussays nothing about whether enough of the system has filed (that isquarters[].quorum— Fields added 2026-09-26 below) or whether NCUA's scrub is complete (freshness.latest_bulk_cycle).quarters[].updated_at— when this quarter's files were last built. It moves when the quarter was rebuilt — because a filing arrived, the collector's code changed, or the weekly rebuild ran — and at no other time; it is absent for a quarter that is held and not yet built. Foraccountsandaccounts_textit is, to within a minute, theingested_aton the quarter's rows. Until 2026-09-20 every sweep re-stamped it for every quarter, so it said when a sweep last ran. It says the files may have changed, not that they did: for what moved, queryfirst_seenon the index (§4).quarters[].dormant_since— present only on adormantquarter: the date of the newest filing NCUA validated for the cycle, i.e. when activity stopped — not the later date on which callbase began calling it dormant, so it does not move if the threshold is retuned.freshness.latest_bulk_cycle— the newest cycle whose official NCUA bulk ZIP has been released: the authorization frontier. A quarter at or before it has been through NCUA's data scrub; a later one holds preliminary, pre-scrub web-service rows that NCUA itself labels non-authorized — usable, but not yet the official record. One value answers it for every quarter, which is why there is no per-quarter flag. (See §5 "Preliminary vs authorized".)freshness.last_successful_run— liveness: when a run last finished without faulting. A run that paused because the NCUA access window was shut still counts, because the machinery worked. A faulted run does not update it, so a broken collector goes visibly stale.quarters[].submission_count/cu_count— filings held, and distinct institutions holding them. They differ by the number of amendments received: an amendment is a newsubmission_idfor the same(cu_number, quarter), so onlycu_countanswers "how many credit unions have filed". Populated from the collector's own holdings, so they count what is archived rather than what is published — see theaccounts_pathnote above.inventory— operational, and about the collector rather than the data. Optional; absent on manifests written before it existed. It carriesunattributed_filings(filings held whose cycle is not yet known, so the per-quarter counts are short by exactly that much) anduniverse(as_of+record_count, how many credit union records NCUA's universe operation last returned). ⚠️record_countis not a count of active charters and is not a denominator. The no-args universe call returns every credit union NCUA holds an id for, including charters that merged or liquidated years ago — ~23,700 against roughly 4,300 that actually file — and the payload carries no status field to filter on. Do not compute a coverage rate from it. The expected-filer denominator isquarters[].quorum.expected(Fields added 2026-09-26 below).freshness.service_watermark— data freshness: the newest filing update-date collected. Monotonic; never moves backwards. Distinct fromlast_successful_runon purpose — if NCUA were unreachable for a week the collector would keep looking alive while the data quietly aged, so a monitor that reads onlylast_successful_runcannot see that. Watch both.
Fields added 2026-09-04. index.submissions_path is set only once index/submissions.parquet has landed (pointer-last, like accounts_path), with index.first_seen_from — the earliest first_seen the index carries; filings held before it have a NULL — and index.rows. quarters[].accounts_text_path is present only for a quarter with published non-numeric text.
Fields added 2026-09-20. quarters[].profiles_path is set once curated/profiles has landed for
the quarter (pointer-last), with profile_rows and profiles_missing — winning filings whose
payload has no Profile block, which are in curated/accounts and not in curated/profiles. It is
0 for 30 of 32 cycles and 1 for two; it is why the join in §4 is a LEFT JOIN.
Fields added 2026-09-26: quorum_policies, quarters[].quorum (schema_version 1.1, the first
minor bump; collect/docs/design/filing-quorum.md §12). Filing quorum answers has enough of the
system filed this quarter to analyse it? Evidence first (Q12): each quarter carries the counts
and exact integer terms, the material outstanding filers and a per-filer table (index/quorum,
§3), and verdicts derived from that evidence under named policies. A consumer with its own tolerance
applies its own thresholds to the same evidence and names its own policy in its own output.
- The object is omitted, never null, for a quarter whose prior cycle is not held: its expected set is unknown. Every other quarter carries one; in this series the first, 2019Q1, has none.
- The measurement, and its version (Q18).
measurementnames it;filing-quorum/2meansexpected= thecuids that filed Q−1 ∪ thecuids that filed this quarter, weighted on both sides by Q−1 total assets (A010; the cycle named byweights_from), with excuses applied only to non-filers. Soexpected = filed + outstanding_count;filedincludesfiled_unweighted, the filers with no Q−1 filing, which count inexpectedandfiledand weigh nothing;excusedfilers are in neither. A filing with nocuid(its payload has noProfileblock) takes thecuidits charter number filed under in Q−1, and is then a filer like any other: counted infiled, weighted, and its assets the weight of Q+1.filed_by_chartercounts them, and their detail rows saycuid_basis = prior_cycle_charter(§3). Only Q−1 is looked at. Where that charter number did not file in Q−1, where twocuids filed under it, or where another filing this quarter already carries thatcuid, the filing stays without one and is in neither count:filedplus those equals the quarter'scu_countas of thecallbase curaterun that measured it, and that run reports a difference rather than failing. A change to any of this is a new measurement version, never a silent shift, and a policy names the measurement it was calibrated on.filing-quorum/1, until 2026-10-02, left a filing with nocuidout offiledand left itscuidinexpected: it listed as outstanding an institution whose filingcurated/accountsheld, in 2020Q3 and in 2026Q2 (collect/docs/design/filing-quorum.md§13). measured_atmoves only when the evidence does (Q17): when any field butmeasured_at,due_date,verdictsanddetail_pathchanges.due_dateis left out on purpose: it is a calendar reference, not a measurement, so a cited deadline override changes it without movingmeasured_at. It is not the run's time; diff on it.quorumis as of the last curate run, not live. Each scheduledcallbase curaterun (daily, about 05:07 UTC) recomputes every quarter's object; a sweep carries it forward unchanged. A sweep between runs can raisecu_countandsubmission_countahead of it, so the equality above holds as of that run, not at every read.due_dateis NCUA's filing deadline for the cycle: the 30th of the month after the cycle ends, 11:59:59 pm Eastern on that date (NCUA's 5300 Call Report FAQs). It is a date, not a timestamp. An extension NCUA grants is a reviewed, cited override in the collector, never an edit of the rule.quorum_policies, at the root, is the registry (Q13): every policy averdictsentry is named after, with itsowner, themeasurementit applies to, three integer thresholds in basis points, therulein words, the dateapprovedand itsevidence. A policy is never edited; a change is a new name. A new consumer's policy is a new entry, with no schema change. Launch entries:floor/1(ownercallbase; 9000 / 9000 / 100) anddefault/1(ownercallseries, the engine's gate for its free peer aggregates; 9800 / 9800 / 50).- The rule is normative; the ratios are informative (Q14). Under a policy a quarter is
reachedwhen all three terms hold:filed × 10000 ≥ min_by_count_bp × expected,assets_filed × 10000 ≥ min_by_assets_bp × assets_expected, andlargest_outstanding_assets × 10000 ≤ max_outstanding_share_bp × assets_expected;indeterminatewhenexpected_basisispartial;belowotherwise.assets_expected,assets_filedandlargest_outstanding_assetsare whole dollars (0 for the last when nobody is outstanding). ⚠️ Compute the rule in 64-bit integers or BigInt, never floating point. Its products exceed 2^53 —assets_filed × 10000is about 2.4 × 10^16 — so a double, and so a plain JavaScript number, can compare them wrongly at the boundary.coverage.by_count,by_assetsandlargest_outstanding_shareare the same quantities as decimals of up to 28 significant digits, for reading; a verdict is never recomputed from them. - The largest outstanding filer is a term, not a list (Q23). Asset coverage bounds the total missing, not who: at 90% by assets the largest credit union, about 7% of the system, could be
absent. The third term says no single outstanding filer may hold more than
max_outstanding_share_bpof Q−1 assets — at 100 bp, about the seven largest. A rule picks the same institutions each quarter by itself; a named list would need upkeep as membership turns over and a merged giant reappears under anothercuid. verdictsmaps policy name tostatus∈reached,below,indeterminate, a pure function of this payload. Consumers gate onstatus(Q15); treat a value you do not recognise as not reached.reached_atandbelow_sinceare an audit trail, the one thing only callbase can keep, because it needs the previous run:reached_atis when a status ofreachedwas first recorded, never removed;below_sinceis present while the status isbelowafter areached_atexists. A consumer that publishes something immutable records the(policy, status, measured_at)it used, so a laterbelow_sincetells its reader the basis moved without retracting anything.- A
reached_atis only as honest as its basis, and one without a basis is never written (Q16).reached_at_basisisobserved, stamped only when a run saw the status go frombelowtoreached; it lags the true crossing by up to one run. Afterindeterminatenoreached_atis invented: abelow → indeterminate → reachedquarter that had none still has none. A quarter alreadyreachedwhen first measured has noreached_at— not the launch date.derivedis reserved for a time computed from each filing's earliest evidence and is not written today (§5). partialmeansindeterminate(Q19).expected_basisispartialwhen Q−1's filers are fewer than 97% of Q−2's (a hollow prior cycle), and every verdict is thenindeterminate, withreached_atandbelow_sinceleft as they were. The terms are still published, so a reader can see why. A quarter with no Q−2 held is judged on Q−1 alone.floor/1is a hard stop for every consumer. Whatever its own policy says, a consumer treatsstatusother thanreachedunderfloor/1as "not viable for any analysis". Areached → belowtransition under it is alerted as an incident (an outage or a restatement); it does not withdraw the filings, which remain valid and published.outstanding_materiallists the outstanding filers at or abovemateriality(0.0025 of Q−1 assets), largest first, each withcuid,cu_number,name(omitted when unknown),last_known_assets(its Q−1A010) andshare.coverage.largest_outstanding_shareis the largest outstanding filer's share, material or not.coverage.by_assets_ceilingis the highestby_assetscan reach given the cessations known;ceiling_basisexcusessays the only known cessations are the reviewed excuses, which are in neither term, so underfiling-quorum/2it is 1.detail_pathappears only after the detail file has landed (pointer-last). If a detail write fails, a quarter whose previousquorumobject had adetail_pathkeeps that object whole — counts, verdicts andmeasured_at— so the summary and its table never disagree; a quarter whose previous object had none is published withoutdetail_path. The detail key is fixed, so between a detail write and the manifest write the file atdetail_pathcan briefly hold newer rows than the object describes. A consumer that needs the two to agree compares the file's row counts byoutcomewith the object'sfiled,outstanding_countandexcused; fromschema_version1.2 it compares the file withdetail_sha256anddetail_bytes(Fields added 2026-10-28 below).
Fields added 2026-10-28: digests, catalog times and curate's liveness (schema_version 1.2;
meta plans/2026-10-17-manifest-digests.md, plans/2026-10-17-curate-liveness.md, plans/2026-10-28-release-input-resilience.md Q6, Q7).
- Every path names its object's bytes. Beside each path the manifest carries the lower-case hex
SHA-256 of the object's exact bytes, and how many bytes there are:
accounts_sha256andaccounts_bytesbesideaccounts_path,accounts_text_sha256andaccounts_text_bytes,profiles_sha256andprofiles_bytes,index.submissions_sha256andindex.submissions_bytes, andquarters[].quorum.detail_sha256anddetail_bytes. A quarter's path names its directory (…/quarter=YYYYQn/); the digest is of the one object in it,data.parquet. - Why a client checks them. An object's key is fixed, and
callbase curatereplaces a quarter's objects one after another before it publishes the manifest that describes them. Between those writes the object at a path can belong to a newer build than the manifest names. A body whose digest or length is not the manifest's is such a body: read the manifest again later, fetch again, and never mix it with files the manifest does describe. Before 1.2 nothing told a client that this had happened. - Under 1.2 every path carries both. A manifest is 1.2 only when every path it names has its digest and its byte count. Once callbase has published a 1.2 manifest it never publishes one without them: a write that would leave a path undigested is refused, and the published manifest stands. A 1.1 manifest may name a path with neither. A client may refuse a 1.2 manifest that names a path without its digest; the engine does.
freshness.last_curate_runandlast_curate_run_idsay whencallbase curatelast published everything it owed (every cycle built or carried, the index and every detail file), and which run that was. A sweep carries them forward unchanged, so they show whatlast_successful_runcannot: a sweep moves that whether or not curate runs. Absent until the first such run.inventory.catalog_atandquarters[].counts_atsay when the catalog behindinventory, and behind each quarter'ssubmission_countandcu_count, was read. A sweep andcallbase curateboth publish the manifest, from catalogs read at different times; each keeps the later of its counts and the published ones, so an older reading never replaces a newer one.
3. Schemas
curated/accounts/quarter=YYYYQn/data.parquet (facts, long/EAV)
| column | type | notes |
|---|---|---|
quarter | VARCHAR | 2026Q1 (also the Hive partition) |
cycle_date | DATE | quarter-end, 2026-03-31 |
cu_number | INTEGER | charter number (the CU key) |
submission_id | BIGINT | unique per filing |
version | SMALLINT | amendment version; curated contains newest only (see §5) |
account_code | VARCHAR | raw NCUA code, exactly as filed — no remapping/series logic |
account_value | DECIMAL(24,8) | money-exact and ratio-exact — see the note below |
ingested_at | TIMESTAMP | when callbase last built this cycle's file — one value for every row of a cycle, not a per-filing time. A cycle is rebuilt only when something it is built from changed (§0), so this moves when the cycle's content may have. For when a filing arrived, use first_seen on the index |
cuid | INTEGER | NCUA's entity id — the key that survives a charter conversion; see Which key below. NULL for the two filings whose payload has no Profile block |
Rows are sorted by cu_number within a file (enables row-group pruning). account_value is
absent rather than zero when a CU didn't report a code — never assume 0. A NULL has two possible
causes, and they are not the same claim; see the note immediately below before reading one.
A value that is not a number is published as text, not here.
account_valueisDECIMAL, so a value NCUA sends as text — a classification, a company name, a date,n/a— cannot be stored in it. The row is still here, one per CU per filing like any other code, withaccount_value IS NULL, and the text is incurated/accounts_text(below). The rule is the value, not the code. callbase parses every value; what parses is a number here, what does not is a text row there. So whether a code appears inaccounts_textdepends on what NCUA sends for it, which is not always what its declared type suggests [read back from the published data 2026-09-22, all 30 cycles]:
code NCUA type what it holds what NCUA sends where it is A700Text Net Worth Classification, if the CU is not new Well Capitalized… (5 values)text, every filing A701Text Net Worth Classification, if the CU is a new credit union (6 values) text, every filing A876Text Company providing share/deposit insurance coverage other than the NCUSIF free text, 460 distinct text (12,405 rows); 2 filers typed a number, which is here as a number ANW0001Dropdown Date of adoption of ASC Topic 326 a date or a fiscal-year label, 37 distinct text (51,010 rows) A999Text RBNW requirement — the NCUA-computed risk-based net-worth requirement, a percentage of assets. Retired: filed 2019Q1–2021Q4, replaced by the 2022Q1 risk-based-capital codes, and no longer in NCUA's current code list 6.92where it applied, the literaln/awhere it did notboth: 6,744 numbers here, 56,366 n/ain textA566BDropdown Does the CU plan to add branches or expand facilities in the next 12 months 0/1here, as a number (146,741 rows), never text A875Dropdown Does the CU maintain share/deposit insurance coverage other than the NCUSIF 0/1here, as a number, never text AAS0010Dropdown CECL adoption indicator 0/1here, as a number, never text ALR0001Dropdown CCULR election (Complex Credit Union Leverage Ratio); from 2022Q1 0/1, blank for most filershere, as a number (13,187 rows), never text ALR0008Dropdown CCULR grace-period indicator; from 2022Q1 0/1, blank for most filershere, as a number (7,822 rows), never text Two corrections to what this note said before 2026-09-22. The five
0/1Dropdowns are numeric here, not NULL: NCUA's web service sends them as digits (verified in the raw bodies of 2021Q4 and 2025Q4 filings), soA566B = 1means the CU answered yes. Read them as flags. And a code can be both:A999is a figure for the credit unions the requirement applied to andn/afor the rest, so a NULLA999here with ann/arow inaccounts_textis not applicable, not not reported. The earlier claim that declared type and observed kind coincide for every code (D1c: "0 of 1,105 codes mixed") was measured on a code list that no longer carriedA999.A NULL means "published as text" only when
curated/accounts_texthas the row — join to it and look. The code does not decide it [measured 2026-09-22, 2026Q1]: a blank is NULL with no text row, whatever the code, soA876has 488 NULLs with text and 3,848 without, andANW00013,636 and 700.A700andA701carry text on every filing. The five Dropdowns are numeric and blank for most filers, soALR0001andALR0008carry thousands of NULLs, none of them text. A NULL with a text row is published as text; a NULL without one is not reported. It is the opposite reading from the one §5's absent ≠ zero rule invites only when the row exists. Measured across 30 live reports the text rows are 0.28% of rows.NCUA's current code list (
collect/reference/ncua/2026Q2/account-codes.csv) declares 9 of 1,112 codes non-numeric; retired codes such asA999are in earlier lists only (collect/reference/ncua/2020Q1/accountDescription_March2020.pdf, p. 37). The list of codes that appear inaccounts_texttherefore changes when NCUA adds, retires or re-encodes a code, and this table is a read-back of the data, not a promise about the schema.
Why scale 8, not 2. This column was specified as
DECIMAL(20,2), which silently corrupts real data. NCUA has two families of numeric account code:Money/Positive Money/Integerhold whole dollars, butDecimal/Positive Decimalhold ratios. Measured against 30 live reports, 15 codes carry more than two decimal places — every interest rate, andA998 Net Worth Ratio:A998 Net Worth Ratio 0.1472 -> DECIMAL(20,2) gives 0.15 A523 Interest rate of New Vehicle Loans 0.067 -> DECIMAL(20,2) gives 0.07
A998is the figure that determines a credit union's net-worth classification, so rounding it does not lose precision — it changes the answer. Scale 8 keeps money exact, keeps ratios well beyond the precision NCUA actually reports, and absorbs the float artefacts NCUA emits (values arriving at scale 17–18, e.g.0.06700000000000001, which collapse back to0.067).Where scale 8 does round something real —
A998, mostly across 2021 [measured 2026-09-20, every cycle]. "Well beyond the precision NCUA actually reports" is true of what a credit union types and not always of what NCUA computes. For 2021Q2–2021Q4, and tapering back to 2019, NCUA servedA998 Net Worth Ratioas a raw computed number —0.05572056716666699— on about one filing in one; since 2022Q1 a handful of filings a cycle carry a computed rate the same way (A568,A595A,A595B). These are published at 8 places, rounded half-to-even. The most any value moves is 5e-9 — the ninth decimal place of a ratio, 0.0000005 of a percentage point — no money value is affected, and every digit NCUA sent is inraw/.DECIMAL(24,8)stands on that measurement. One thing follows for a reader: do not re-derive a net-worth classification by comparingA998with a threshold. Read NCUA's own determination,A700andA701, fromcurated/accounts_text— which is the right source in any cycle, and the only one that cannot be moved by a ninth place.curatecounts these values every run, apart from the float noise, as rounded beyond float noise.
curated/profiles/quarter=YYYYQn/data.parquet (the credit-union dimension)
Live 2026-09-20; read back the same day (§0). NCUA's Credit Union Profile is a separate form from the 5300, filed with it and returned in the same payload. This is that form, one row per winning filing — the same filing
curated/accountspublishes — sorted bycu_number, 187 columns: eight of callbase's, then all 179 of NCUA's.
callbase's columns — from its catalog, never from the Profile block:
| column | type | notes |
|---|---|---|
quarter | VARCHAR | |
cycle_date | DATE | |
cu_number | INTEGER | the charter the filing was made under |
cuid | INTEGER | NCUA's entity id; see Which key below |
submission_id | BIGINT | joins to index/submissions.parquet |
version | SMALLINT | |
observed_at | TIMESTAMP | when callbase fetched this filing. Never NULL |
observed_at_source | VARCHAR | first_seen (stamped at acquisition) or archived (the archive object's write time, for a filing held before the stamp existed) |
NCUA's columns, 179. Each is NCUA's field under a snake_case name — CreditUnionTypeID is credit_union_type_id, Acct_892A is acct_892a — typed as NCUA's OpenAPI document declares it: INTEGER (134) or VARCHAR (45). Declared type, not apparent type: a date, a Yes/No and a number
in a string all stay the string NCUA sent. The full list — NCUA's name, the column, its type and its
temporal class — is collect/reference/callbase/profile-fields.csv. The ones most read:
| column | type | class | notes |
|---|---|---|---|
credit_union_name | VARCHAR | as_of_fetch | name. As-of-fetch: today's name on every cycle's row — a rename cannot be seen here |
credit_union_type_id | VARCHAR | cycle | FCU, FISCU or NFICU. IN ('FCU','FISCU') is the federally insured population |
state | VARCHAR | cycle | two-letter state |
charter_state_code | VARCHAR | cycle | chartering state, spelled out |
region | VARCHAR | cycle | NCUA region |
peer_group | VARCHAR | cycle | NCUA asset-size peer group. NULL before 2022Q4 |
is_low_income | VARCHAR | cycle | Yes/No — low-income designation |
fom_type | VARCHAR | cycle | field of membership type |
date_chartered | VARCHAR | unproven | YYYY, as NCUA sends it |
date_insured | VARCHAR | unproven | M/D/YYYY; 1/1/1900 is NCUA's "unknown" |
rssd_number | VARCHAR | cycle | Federal Reserve RSSD id — an external join key. A string, as declared |
acct_pg0001 | VARCHAR | cycle | LEI, where the credit union has one (populated from 2023Q4) |
acct_891 | VARCHAR | cycle | website |
organizational_email | VARCHAR | cycle | the institution's contact address |
Five of NCUA's fields are renamed, each because its own name would mislead:
| NCUA's field | column | why |
|---|---|---|
CharterNumber | profile_charter_number | a signal, never a key. NCUA stamps it with the institution's charter as of the fetch, so on a filing made before a conversion it names a charter that did not yet exist. It differs from cu_number on 1,676 rows — 110 charter conversions |
CreditUnionId | profile_credit_union_id | the block's own copy of the entity id. Read cuid |
CycleDate | profile_cycle_date | the block's own copy, as NCUA's M/D/YYYY string. Read cycle_date |
Assets | assets_prior_cycle | the previous cycle's total assets — except on 2026Q2, the newest cycle when measured, where it has been the cycle's own — whether a cycle's rows change once a newer one is published is not measured [measured 2026-09-22: at 2026Q2 equal to its own A010 for 4,299 of 4,299; at 2026Q1 to the prior cycle's for 4,320 of 4,336]. For a credit union's assets read A010 from curated/accounts, always |
NumberOfMembers | members_prior_cycle | likewise, including the newest cycle. Read A083 |
What each column's value is a value of — measured, not assumed (2026-09-20, per field, in the reference file above). A row is keyed by cycle, and most of it is that cycle's; not all of it is:
| class | columns | read it as |
|---|---|---|
cycle | 136 | what was true for that cycle. Proven by cycles of one institution, fetched together, differing |
as_of_fetch | 2 — profile_charter_number, credit_union_name | what NCUA held at observed_at, whatever the row's cycle. Of 305 charters that renamed between 2019Q1 and 2026Q1, the 2019Q1 row carries the 2026 name for 303 |
prior_cycle | 2 | the cycle before — except on 2026Q2's rows, the newest cycle when measured (above) |
every_cycle | 3 | the filing's own dates |
unproven | 34 | never differs between cycles: stable, or as-of-fetch. Mostly flags that are 0 for nearly everybody. Do not build history on one |
Three rules a reader must know:
- Before 2022Q4,
assets_prior_cycle,members_prior_cycleandpeer_groupare NULL. NCUA serves a placeholder there — 0, 0 and the smallest peer group, for every institution, in 78,530 of 78,530 rows — and a multi-billion credit union in the smallest peer group for fourteen cycles is a wrong answer, not a faithful one. The rule is keyed on the cycle, so a genuine zero from 2022Q4 on is published as filed. It is the one place this table withholds what NCUA sent; the bytes are in the raw zone. - An empty string is NULL. NCUA's
''means "not reported", which is what NULL already means. Nothing else is normalised:YesisYes,092021is092021, and1/1/1900is what NCUA sent. - It does not cover
curated/accounts. Two winning filings in the archive have noProfileblock (charter 21782 at 2020Q3, charter 566 at 2026Q2).quarters[].profiles_missingsays so per cycle. Join LEFT, and decide what a missing row means for your question.
There is no ingested_at: observed_at is this table's time. With no run's clock in a row, a
cycle's file is byte-identical from one run to the next when nothing in it changed — cacheable on ETag, which curated/accounts is not.
Which key — cu_number, cuid, rssd_number
cu_number is a charter key, not an entity key. A credit union that converts its charter is
issued a new number, and its series under the old one simply stops: nothing errors, a $700M
institution appears to cease. 99 institutions in the archive have done it; three have done it twice.
| key | on | use it for | where it fails |
|---|---|---|---|
cu_number | every table | one charter; joining to NCUA's own files, which are keyed on it | a conversion mints a new one |
cuid | accounts, profiles, the index | one institution over time. Equal across all 108 conversions measured; never on two current rows in a cycle; no charter has two | NULL for the two filings with no Profile block. NCUA's own id: it has been seen to change once (SPIKE-002 §1.2) |
rssd_number | profiles | joining to Federal Reserve data | on profiles only, a string, and an external identifier with its own lifecycle |
None is a perfect entity key, and callbase does not pretend one is (stage-2-curated-zone.md D14). For a series over time, filter on cuid — §4 has the recipe.
index/submissions.parquet (master index, all quarters)
Verified 2026-07-29 against 203,717 live filings across 14 years — full write-up in
collect/docs/design/index-schema-verification.md.validation_datewasDATEand is nowTIMESTAMP: 80.4% of filings carry a non-midnight validation time, and truncating it would hide an amendment that lands on the day an article was published.
versionandstatusare derived by callbase, not reported by NCUA. The service returns exactly one filing per(cu_number, cycle_date)— the current one — and no endpoint accepts asubmission_id. An amendment is recognised as "a pair we already hold, now carrying a differentsubmission_id", which is only possible because callbase kept the previous one. An amendment that occurred before callbase began collecting is undetectable, and always will be. | column | type | notes | |---|---|---| |quarter| VARCHAR | | |cycle_date| DATE | | |cu_number| INTEGER | | |submission_id| BIGINT | | |version| SMALLINT | | |validation_date| TIMESTAMP | NCUA validation timestamp — not a date; 80% carry a real time | |first_seen| TIMESTAMP | when callbase first ingested this submission (use for "recent") | |source| VARCHAR |web(authoritative REST web service),seed(imported prior web-service capture), orbulk(backfill/cross-check, amendment-incomplete) | |status| VARCHAR |currentorsuperseded— derived by callbase, see note below | |content_hash| VARCHAR | integrity / change detection | |account_rows| INTEGER | row count in curated for this filing | |cuid| INTEGER | NCUA's entity id, as oncurated/accounts. On 148,014 of 148,016 rows |
Row grain (decided 2026-09-04): one row per submission held, superseded ones included — status says which stands. The amendment history is what raw/ uniquely holds, and this is the cheapest
place to surface it (D9). first_seen is NULL for the filings the collector held before it stamped
them — nothing in the archive records when callbase first held a filing — and account_rows is NULL
for a filing the current build did not write. validation_date and content_hash are recoverable
from the archived body and are filled for those filings by callbase reconcile. Never a synthetic
value: a "recent" query sees what was recorded from index.first_seen_from on, and the manifest
says so.
curated/submissions/quarter=.../data.parquet is the same schema, partitioned per quarter — not built.
curated/accounts_text/quarter=YYYYQn/data.parquet (every non-numeric value, as filed)
| column | type | notes |
|---|---|---|
quarter | VARCHAR | |
cycle_date | DATE | |
cu_number | INTEGER | |
submission_id | BIGINT | the winning filing only, like curated/accounts |
account_code | VARCHAR | a code whose value did not parse as a number — five today: A700, A701, A876, ANW0001, A999 (the note in §3 above) |
account_text | VARCHAR | the value exactly as filed, trimmed — Yes, Well Capitalized, … |
ingested_at | TIMESTAMP |
One row per non-numeric value the winning filing carried; the matching curated/accounts row keeps its
NULL. A quarter with no such values has no file, and no accounts_text_path in the manifest. This is
D1's blueprint built as D1c, because a consumer arrived — the engine's cculr_election, which was
then thought to read ALR0001 as Yes/No. It does not: NCUA sends that code as 0/1, which is
numeric and in curated/accounts [read back 2026-09-22]; what the table carries is the five
codes above. The routing is by observed value (AccountValueParser), so a code NCUA re-encodes moves
between the two tables without a change here.
index/quorum/quarter=YYYYQn/data.parquet (filing quorum, one row per expected filer)
| column | type | notes |
|---|---|---|
quarter | VARCHAR | 2026Q2 (also the Hive partition) |
cuid | INTEGER | the key — one row per cuid, so an institution that converted its charter is one row (Which key above) |
cu_number | INTEGER | for display: the charter it filed under this quarter, or, if it has not, the one it filed under in Q−1 |
cu_name | VARCHAR | for display, chosen like cu_number |
outcome | VARCHAR | filed, outstanding or excused |
weight_assets | DECIMAL(24,8) | its Q−1 total assets (A010), in dollars. NULL means no prior-cycle filing: a filer new since Q−1, counted in filed_unweighted. 0 means a Q−1 filing with no A010 value (or one of zero): it counts in expected, weighs nothing and is not filed_unweighted |
weight_as_of | VARCHAR | the quarter weight_assets is from — the manifest's quorum.weights_from; NULL where weight_assets is |
expected_basis | VARCHAR | prior_cycle or partial, as on the quarter's quorum |
cuid_basis | VARCHAR | where cuid came from: profile, the filing's own Profile block; or prior_cycle_charter, the cuid the charter number filed under in Q−1, for a filing with no Profile block (§2, filing-quorum/2). On an outstanding or excused row it is the Q−1 filing's |
The rows behind a quarter's quarters[].quorum (§2, Fields added 2026-09-26), written only for a
quarter that carries one, and pointed at by its detail_path. The row count is expected + excused: every expected filer, plus the excused filers, who are in neither term. By outcome the
rows count filed, outstanding_count and excused; the sum of weight_assets over the filed and outstanding rows is assets_expected before it is rounded to whole dollars. The engine groups these
rows by peer group to form n and expected_n. Keyed on cuid, never cu_number. The file is
rewritten only when its rows change, and its key is fixed: §2's detail_path rule says what that
means for reading it beside the manifest.
4. Querying from DuckDB (the supported recipes)
Run 2026-09-20 against
https://data.quartercall.com, DuckDB v1.5. Every recipe in this block was executed as written and returned the result beside it. The recipes this section carried until then had never been run (§0), and did not work: they read…/quarter=2026Q1/*.parquetand…/accounts/**/*.parquet, and DuckDB cannot glob over plain HTTPS — there is nothing to list. A quarter is one object,data.parquet, so name it; and for several quarters take the list from the manifest, which is what the manifest is for.
Zero-setup, public HTTPS, no credentials:
-- one cycle: name the file -> 4,782,608 rows
SELECT count(*) FROM read_parquet(
'https://data.quartercall.com/curated/accounts/quarter=2026Q1/data.parquet');
-- every cycle: the manifest lists them -> 32 files
SET VARIABLE base = 'https://data.quartercall.com/';
SET VARIABLE accounts = (
SELECT list(getvariable('base') || q.accounts_path || 'data.parquet')
FROM (SELECT unnest(quarters) AS q FROM read_json(getvariable('base') || 'index/manifest.json'))
WHERE q.accounts_path IS NOT NULL); -- accounts_path is what means "readable" (§2)
-- one INSTITUTION's total assets over time. On cuid, not cu_number: this credit union converted
-- its charter in 2026, and on cu_number = 68696 the series stops at 2026Q1 with no error (§3).
SELECT quarter, cu_number, account_value AS total_assets
FROM read_parquet(getvariable('accounts'))
WHERE cuid = 5163 AND account_code = 'A010'
ORDER BY cycle_date; -- … 2026Q1 68696 682,119,422 · 2026Q2 24989 698,197,125
-- recently filed (last 7 days)
SELECT cu_number, cuid, submission_id, validation_date, first_seen
FROM read_parquet('https://data.quartercall.com/index/submissions.parquet')
WHERE first_seen >= now() - INTERVAL 7 DAY
ORDER BY first_seen DESC;
-- the dimension: total assets by charter type for a cycle. LEFT JOIN — profiles does not cover
-- accounts (§3) — and keep the unmatched visible rather than dropping them in a WHERE.
SELECT coalesce(p.credit_union_type_id, '(no profile row)') AS type,
count(*) AS credit_unions, sum(a.account_value) AS total_assets
FROM read_parquet('https://data.quartercall.com/curated/accounts/quarter=2026Q1/data.parquet') a
LEFT JOIN read_parquet('https://data.quartercall.com/curated/profiles/quarter=2026Q1/data.parquet') p
USING (cu_number)
WHERE a.account_code = 'A010'
GROUP BY 1 ORDER BY 3 DESC; -- FCU 2,672 $1,299.2B · FISCU 1,578 $1,184.9B · NFICU 86 $23.7B The federally insured population is credit_union_type_id IN ('FCU', 'FISCU'). The 86 NFICU filers — state-chartered and privately insured, $23.7B at 2026Q1, about 1% of the system — are
published here like any other filer. QuarterCall's own figures cover federally insured credit unions
(owner, 2026-09-19), which is 4,250 filers and $2,484.0B at 2026Q1, and this filter is how to
reproduce them. The type is the cycle's: it moved with the cycle for 99 of 99 institutions that
changed charter.
Corrected 2026-09-20. This note first said the
NFICUfilers are "not in NCUA's public bulk files". They are — all 86, asCU_TYPE3, with the same $23,718,765,117 of total assets [measured:call-report-data-2026-03.zip,FOICU.txtandFS220.txt]. What omits them is the engine's legacy bulk build, which filters toCU_TYPE1 and 2 unless told otherwise (build_db3.py,include_non_federally_insured=False). The population was always a choice made downstream of NCUA, and a total that includesNFICUdiffers from QuarterCall's for that reason, not from NCUA's file.
Clean view names via the published catalog:
.read https://data.quartercall.com/catalog/init.sql -- defines: accounts, submissions, cycles
SELECT count(*) FROM accounts WHERE quarter = '2026Q1'; catalog/init.sql is not built (§0), and the sketch below cannot be built as drawn: it globs over
HTTPS, which the note above explains does not work. When it is built it will list the files, from the
manifest. Roughly:
CREATE OR REPLACE VIEW accounts AS
SELECT * FROM read_parquet(
'https://data.quartercall.com/curated/accounts/**/*.parquet', hive_partitioning => true);
CREATE OR REPLACE VIEW submissions AS
SELECT * FROM read_parquet('https://data.quartercall.com/index/submissions.parquet'); 5. Semantics clients can rely on
cu_numberis a charter,cuidis the institution. A charter conversion mints a newcu_number;cuidis equal across every conversion measured. For anything over time, filter oncuid(§3 Which key, §4).curated/profilesis one row per winning filing, keyed likecurated/accounts, and most of it is the cycle's — butcredit_union_nameandprofile_charter_numberare as-of-fetch, and three columns are NULL before 2022Q4 (§3). It does not covercurated/accounts: join LEFT.- Deduped & newest-wins:
curated/accountsandsubmissionswithstatus='current'contain exactly one version per(cu_number, quarter)— the latest amendment. callbase does the dedup; clients never resolve amendments themselves. (Full amendment history is available viasupersededrows in the index.) - Superseded versions exist only here. NCUA replaces a filing when it is amended: a re-fetch
returns whatever is current, and no endpoint accepts a
submission_id, so a prior version cannot be retrieved from the service at any point. Thesupersededrows in this index, and the payloads in callbase's raw zone, are the only record that an earlier version existed. Correspondingly, callbase's amendment history begins when its collection began — earlier amendments are already unrecoverable. - Amendment-complete, beyond what NCUA's bulk files offer: because the web service is the
backbone, callbase captures amendments to any prior cycle (which the published bulk files
silently omit). When the same filing exists from multiple sources,
web/seedalways supersedesbulk. Abulk-sourced row means "no web-service capture of this filing (yet)." - Atomic snapshots: a client reading after any run sees a consistent dataset — never a partial
write. Read
manifest.jsonfirst if you need the matching file set. - Dormant ≠ immutable: a quarter with
status: "dormant"has stopped changing and can be cached for a long time, but not for ever — a late amendment reopens it. Re-read the manifest to find out. - Accuracy is NCUA's disclaimer, and it travels with the data. callbase reproduces what the CUO
Data Service returned; it does not validate or correct it. NCUA's Rules of Behavior state that the
data comes from reliable sources but "no guarantee is given as to data accuracy", that it has
generally had only a "first level review/validation but may still be considered raw data", and
that conclusions drawn from it are the user's own and are not to be attributed to NCUA. Any
onward publication should carry the same caveat.
One known failure mode is worth naming because it is invisible in a typed column: values meant as **basis points or percentages, filed as whole integers**. Ratio-typed codes — interest rates, `A998 Net Worth Ratio` — are where to expect it. Errors can be reported to `[email protected]`. - Preliminary vs authorized: current-cycle data collected from the web service before NCUA's
quarterly data scrub is preliminary — NCUA labels such pre-scrub data non-authorized. callbase
still publishes it and never presents it as the official record; once NCUA releases that cycle's
bulk ZIP,
freshness.latest_bulk_cycleadvances to it. Don't headline the figures of a quarter later thanlatest_bulk_cycleas final — and until that field is tracked (§0), treat every quarter that way. - When a filing was first certified — filing quorum's clock (
collect/docs/design/filing-quorum.md§12 Q20). Nocertified_atis published. NCUA'sProfile.CertificationDate, incurated/profilesascertification_date, survives an amendment made after the cycle closes, but not a re-certification while it is open: in 2026Q2, 105 of 513 multi-version filers carry a later version's date [measured 2026-09-26, spike2026-09-26-quorum-policy@9924aa0]. So the earliest evidence of a filer's filing ismin(validation_date)over its rows inindex/submissions.parquet, superseded ones included — and for a filing amended before callbase held it, that is the amendment's. Noreached_atisderivedfrom it yet; everyreached_attoday isobserved(§2). - Raw codes only: callbase stores account codes as filed. Series-break / account-code-change
comparability is a downstream concern (see
analytics-projects), deliberately not in this layer. - Absent ≠ zero: a missing
account_codefor a CU means "not reported," not 0. A present row with a NULLaccount_valuemeans published as text whencurated/accounts_texthas the row, and not reported when it does not — a blank arrives as NULL on any code (§3). Neither ever means 0.
6. Versioning policy
schema_version 1.0 begins at the first published curated file. Until then this document is
partly a draft — §0 records three types that were specified before anyone had measured a payload and
were corrected once one arrived, all under a stationary 1.0. That was free because nothing was
published and no client could have depended on it. After the first publish it stops being free: every subsequent change follows the rules below, and a correction of the same kind would cost a major
bump. §0's table is the record of what was changed while it was still cheap, and it is retained
rather than tidied away precisely so the 1.0 a client sees is not mistaken for a version that has
been stable all along.
- Additive changes (new optional column, new manifest field, a new table such as
curated/accounts_text) keepschema_versionmajor, bump minor. Nothing a client already reads changes, so nothing a client already wrote breaks. First applied 2026-09-26:quorum_policies,quarters[].quorumandindex/quorummake it1.1, from the first manifest published with them. The additive fields and tables before them —index,accounts_text,profiles,cuid, among others — shipped under1.0without a bump; the changelog says so. A client that read1.0reads1.1unchanged. Applied again 2026-10-28: the digests,freshness.last_curate_run,inventory.catalog_atandquarters[].counts_atmake it1.2.1.2also carries a rule1.1did not: every path names its digest and its byte count. A client that read1.1reads1.2unchanged. - Breaking changes (column removed/retyped, layout change) bump the major
schema_versionand are published side-by-side under a versioned prefix (e.g./v2/...) for a migration window. - The current major version's paths above are stable for the life of that major version.
- Clients should read
schema_versionfrom the manifest and refuse a major they do not recognise, rather than assume forward compatibility. Minors are safe to ignore.