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 bump schema_version and 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:

ClauseAs specifiedLive realityResolution
§2 manifestassumed curated/, index/submissions.parquet, catalog/ all existnone of them didunbuilt fields omitted, never written as null; quarters: [] means "nothing published"
§3 account_value typeDECIMAL(20,2)some codes hold text, dates or n/aNULL 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 scaleDECIMAL(20,2)15 codes hold ratios; A998 Net Worth Ratio = 0.1472widened 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

SectionStatus
§1 index/manifest.jsonlive — 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 publicationlive 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_sincelive 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_atredefined 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 rewrittenchanged 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_pathlive 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 indexlive — {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, catalogunverified — omitted until real
§2 quarters[].sealed, quarters[].authorizedremoved 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 inventorylive — operational; universe.record_count is every CU record NCUA holds, not a denominator
§2 quorum_policies, quarters[].quorumunverified — 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.parquetunverified — 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/accountslive — 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.parquetlive — 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.parquetlive 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 recipesrun 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 recipeslive 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 URLlive — 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 semanticsmixed; 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_atunverified — added 2026-10-28 with the digests. Written by the collector; not yet read back from the live domain
§6 versioningin 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/accounts was 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, for DECIMAL(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_kindcatalogueThe files its rows name
fred-series-v1fred/series.parquet, one row per seriesseries=<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 publishedeach 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:

ColumnTypeMeaning
dataset_idVARCHARhpi_po_state
hpi_type, hpi_flavor, frequency, levelVARCHARtraditional, purchase-only, quarterly, State
unitsVARCHARindex, 1991Q1=100
source, licence, noticeVARCHARFHFA; public domain, HPI® trademark, cite “Source: FHFA HPI®”; “This product uses FHFA data but is neither endorsed nor certified by FHFA.”
source_urlVARCHARThe fixed source URL above
source_sha256, transformVARCHAR, INTEGERRaw bytes' SHA-256 and positive connector transform version; together with dataset_id, the artifact identity
path, source_pathVARCHARPanel and raw source paths, relative to external/fhfa/, matching the layout above
file_sha256VARCHARExact panel bytes' SHA-256; integrity and publication pin
content_sha256VARCHARCanonical typed panel content's SHA-256, independent of Parquet encoding
writer_duckdb_version, writer_optionsVARCHARActual DuckDB engine version (SELECT version()) and JSON object with explicit compression and parquet_version COPY options
row_countBIGINTPanel row count
first_quarter, last_quarterVARCHARObservation coverage (YYYYQn), not vintage identifiers
fetched_at, published_atVARCHARUTC 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 advertising index/submissions.parquet before it exists would send every client to a 404. index, base_url and latest_bulk_cycle are 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_path is what means "readable", not a non-empty quarters. 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 for accounts_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 into curated/, and not in index/submissions.parquet. Iterating quarters[] 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 quarter open again. 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 dormant describes what has been observed rather than what will happen. A client must treat any status it does not recognise as open. status says nothing about whether enough of the system has filed (that is quarters[].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. For accounts and accounts_text it is, to within a minute, the ingested_at on 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, query first_seen on the index (§4).

  • quarters[].dormant_since — present only on a dormant quarter: 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 new submission_id for the same (cu_number, quarter), so only cu_count answers "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 the accounts_path note above.

  • inventory — operational, and about the collector rather than the data. Optional; absent on manifests written before it existed. It carries unattributed_filings (filings held whose cycle is not yet known, so the per-quarter counts are short by exactly that much) and universe (as_of + record_count, how many credit union records NCUA's universe operation last returned). ⚠️ record_count is 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 is quarters[].quorum.expected (Fields added 2026-09-26 below).

  • freshness.service_watermark — data freshness: the newest filing update-date collected. Monotonic; never moves backwards. Distinct from last_successful_run on purpose — if NCUA were unreachable for a week the collector would keep looking alive while the data quietly aged, so a monitor that reads only last_successful_run cannot 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). measurement names it; filing-quorum/2 means expected = the cuids that filed Q−1 ∪ the cuids that filed this quarter, weighted on both sides by Q−1 total assets (A010; the cycle named by weights_from), with excuses applied only to non-filers. So expected = filed + outstanding_count; filed includes filed_unweighted, the filers with no Q−1 filing, which count in expected and filed and weigh nothing; excused filers are in neither. A filing with no cuid (its payload has no Profile block) takes the cuid its charter number filed under in Q−1, and is then a filer like any other: counted in filed, weighted, and its assets the weight of Q+1. filed_by_charter counts them, and their detail rows say cuid_basis = prior_cycle_charter (§3). Only Q−1 is looked at. Where that charter number did not file in Q−1, where two cuids filed under it, or where another filing this quarter already carries that cuid, the filing stays without one and is in neither count: filed plus those equals the quarter's cu_count as of the callbase curate run 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 no cuid out of filed and left its cuid in expected: it listed as outstanding an institution whose filing curated/accounts held, in 2020Q3 and in 2026Q2 (collect/docs/design/filing-quorum.md §13).
  • measured_at moves only when the evidence does (Q17): when any field but measured_at, due_date, verdicts and detail_path changes. due_date is left out on purpose: it is a calendar reference, not a measurement, so a cited deadline override changes it without moving measured_at. It is not the run's time; diff on it.
  • quorum is as of the last curate run, not live. Each scheduled callbase curate run (daily, about 05:07 UTC) recomputes every quarter's object; a sweep carries it forward unchanged. A sweep between runs can raise cu_count and submission_count ahead of it, so the equality above holds as of that run, not at every read.
  • due_date is 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 a verdicts entry is named after, with its owner, the measurement it applies to, three integer thresholds in basis points, the rule in words, the date approved and its evidence. 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 (owner callbase; 9000 / 9000 / 100) and default/1 (owner callseries, 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 reached when all three terms hold: filed × 10000 ≥ min_by_count_bp × expected, 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. assets_expected, assets_filed and largest_outstanding_assets are 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 × 10000 is 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_assets and largest_outstanding_share are 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_bp of 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 another cuid.
  • verdicts maps policy name to status ∈ reached, below, indeterminate, a pure function of this payload. Consumers gate on status (Q15); treat a value you do not recognise as not reached. reached_at and below_since are an audit trail, the one thing only callbase can keep, because it needs the previous run: reached_at is when a status of reached was first recorded, never removed; below_since is present while the status is below after a reached_at exists. A consumer that publishes something immutable records the (policy, status, measured_at) it used, so a later below_since tells its reader the basis moved without retracting anything.
  • A reached_at is only as honest as its basis, and one without a basis is never written (Q16). reached_at_basis is observed, stamped only when a run saw the status go from below to reached; it lags the true crossing by up to one run. After indeterminate no reached_at is invented: a below → indeterminate → reached quarter that had none still has none. A quarter already reached when first measured has no reached_at — not the launch date. derived is reserved for a time computed from each filing's earliest evidence and is not written today (§5).
  • partial means indeterminate (Q19). expected_basis is partial when Q−1's filers are fewer than 97% of Q−2's (a hollow prior cycle), and every verdict is then indeterminate, with reached_at and below_since left 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/1 is a hard stop for every consumer. Whatever its own policy says, a consumer treats status other than reached under floor/1 as "not viable for any analysis". A reached → below transition under it is alerted as an incident (an outage or a restatement); it does not withdraw the filings, which remain valid and published.
  • outstanding_material lists the outstanding filers at or above materiality (0.0025 of Q−1 assets), largest first, each with cuid, cu_number, name (omitted when unknown), last_known_assets (its Q−1 A010) and share. coverage.largest_outstanding_share is the largest outstanding filer's share, material or not. coverage.by_assets_ceiling is the highest by_assets can reach given the cessations known; ceiling_basis excuses says the only known cessations are the reviewed excuses, which are in neither term, so under filing-quorum/2 it is 1.
  • detail_path appears only after the detail file has landed (pointer-last). If a detail write fails, a quarter whose previous quorum object had a detail_path keeps that object whole — counts, verdicts and measured_at — so the summary and its table never disagree; a quarter whose previous object had none is published without detail_path. The detail key is fixed, so between a detail write and the manifest write the file at detail_path can briefly hold newer rows than the object describes. A consumer that needs the two to agree compares the file's row counts by outcome with the object's filed, outstanding_count and excused; from schema_version 1.2 it compares the file with detail_sha256 and detail_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_sha256 and accounts_bytes beside accounts_path, accounts_text_sha256 and accounts_text_bytes, profiles_sha256 and profiles_bytes, index.submissions_sha256 and index.submissions_bytes, and quarters[].quorum.detail_sha256 and detail_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 curate replaces 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_run and last_curate_run_id say when callbase curate last 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 what last_successful_run cannot: a sweep moves that whether or not curate runs. Absent until the first such run.
  • inventory.catalog_at and quarters[].counts_at say when the catalog behind inventory, and behind each quarter's submission_count and cu_count, was read. A sweep and callbase curate both 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)

columntypenotes
quarterVARCHAR2026Q1 (also the Hive partition)
cycle_dateDATEquarter-end, 2026-03-31
cu_numberINTEGERcharter number (the CU key)
submission_idBIGINTunique per filing
versionSMALLINTamendment version; curated contains newest only (see §5)
account_codeVARCHARraw NCUA code, exactly as filed — no remapping/series logic
account_valueDECIMAL(24,8)money-exact and ratio-exact — see the note below
ingested_atTIMESTAMPwhen 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
cuidINTEGERNCUA'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_value is DECIMAL, 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, with account_value IS NULL, and the text is in curated/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 in accounts_text depends 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]:

codeNCUA typewhat it holdswhat NCUA sendswhere it is
A700TextNet Worth Classification, if the CU is not newWell Capitalized … (5 values)text, every filing
A701TextNet Worth Classification, if the CU is a new credit union(6 values)text, every filing
A876TextCompany providing share/deposit insurance coverage other than the NCUSIFfree text, 460 distincttext (12,405 rows); 2 filers typed a number, which is here as a number
ANW0001DropdownDate of adoption of ASC Topic 326a date or a fiscal-year label, 37 distincttext (51,010 rows)
A999TextRBNW 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 list6.92 where it applied, the literal n/a where it did notboth: 6,744 numbers here, 56,366 n/a in text
A566BDropdownDoes the CU plan to add branches or expand facilities in the next 12 months0 / 1here, as a number (146,741 rows), never text
A875DropdownDoes the CU maintain share/deposit insurance coverage other than the NCUSIF0 / 1here, as a number, never text
AAS0010DropdownCECL adoption indicator0 / 1here, as a number, never text
ALR0001DropdownCCULR election (Complex Credit Union Leverage Ratio); from 2022Q10 / 1, blank for most filershere, as a number (13,187 rows), never text
ALR0008DropdownCCULR grace-period indicator; from 2022Q10 / 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/1 Dropdowns are numeric here, not NULL: NCUA's web service sends them as digits (verified in the raw bodies of 2021Q4 and 2025Q4 filings), so A566B = 1 means the CU answered yes. Read them as flags. And a code can be both: A999 is a figure for the credit unions the requirement applied to and n/a for the rest, so a NULL A999 here with an n/a row in accounts_text is 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 carried A999.

A NULL means "published as text" only when curated/accounts_text has 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, so A876 has 488 NULLs with text and 3,848 without, and ANW0001 3,636 and 700. A700 and A701 carry text on every filing. The five Dropdowns are numeric and blank for most filers, so ALR0001 and ALR0008 carry 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 as A999 are in earlier lists only (collect/reference/ncua/2020Q1/accountDescription_March2020.pdf, p. 37). The list of codes that appear in accounts_text therefore 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/Integer hold whole dollars, but Decimal/Positive Decimal hold ratios. Measured against 30 live reports, 15 codes carry more than two decimal places — every interest rate, and A998 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

A998 is 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 to 0.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 served A998 Net Worth Ratio as 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 in raw/. DECIMAL(24,8) stands on that measurement. One thing follows for a reader: do not re-derive a net-worth classification by comparing A998 with a threshold. Read NCUA's own determination, A700 and A701, from curated/accounts_text — which is the right source in any cycle, and the only one that cannot be moved by a ninth place. curate counts 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/accounts publishes — sorted by cu_number, 187 columns: eight of callbase's, then all 179 of NCUA's.

callbase's columns — from its catalog, never from the Profile block:

columntypenotes
quarterVARCHAR
cycle_dateDATE
cu_numberINTEGERthe charter the filing was made under
cuidINTEGERNCUA's entity id; see Which key below
submission_idBIGINTjoins to index/submissions.parquet
versionSMALLINT
observed_atTIMESTAMPwhen callbase fetched this filing. Never NULL
observed_at_sourceVARCHARfirst_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:

columntypeclassnotes
credit_union_nameVARCHARas_of_fetchname. As-of-fetch: today's name on every cycle's row — a rename cannot be seen here
credit_union_type_idVARCHARcycleFCU, FISCU or NFICU. IN ('FCU','FISCU') is the federally insured population
stateVARCHARcycletwo-letter state
charter_state_codeVARCHARcyclechartering state, spelled out
regionVARCHARcycleNCUA region
peer_groupVARCHARcycleNCUA asset-size peer group. NULL before 2022Q4
is_low_incomeVARCHARcycleYes/No — low-income designation
fom_typeVARCHARcyclefield of membership type
date_charteredVARCHARunprovenYYYY, as NCUA sends it
date_insuredVARCHARunprovenM/D/YYYY; 1/1/1900 is NCUA's "unknown"
rssd_numberVARCHARcycleFederal Reserve RSSD id — an external join key. A string, as declared
acct_pg0001VARCHARcycleLEI, where the credit union has one (populated from 2023Q4)
acct_891VARCHARcyclewebsite
organizational_emailVARCHARcyclethe institution's contact address

Five of NCUA's fields are renamed, each because its own name would mislead:

NCUA's fieldcolumnwhy
CharterNumberprofile_charter_numbera 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
CreditUnionIdprofile_credit_union_idthe block's own copy of the entity id. Read cuid
CycleDateprofile_cycle_datethe block's own copy, as NCUA's M/D/YYYY string. Read cycle_date
Assetsassets_prior_cyclethe 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
NumberOfMembersmembers_prior_cyclelikewise, 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:

classcolumnsread it as
cycle136what was true for that cycle. Proven by cycles of one institution, fetched together, differing
as_of_fetch2 — profile_charter_number, credit_union_namewhat 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_cycle2the cycle before — except on 2026Q2's rows, the newest cycle when measured (above)
every_cycle3the filing's own dates
unproven34never 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:

  1. Before 2022Q4, assets_prior_cycle, members_prior_cycle and peer_group are 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.
  2. An empty string is NULL. NCUA's '' means "not reported", which is what NULL already means. Nothing else is normalised: Yes is Yes, 092021 is 092021, and 1/1/1900 is what NCUA sent.
  3. It does not cover curated/accounts. Two winning filings in the archive have no Profile block (charter 21782 at 2020Q3, charter 566 at 2026Q2). quarters[].profiles_missing says 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.

keyonuse it forwhere it fails
cu_numberevery tableone charter; joining to NCUA's own files, which are keyed on ita conversion mints a new one
cuidaccounts, profiles, the indexone institution over time. Equal across all 108 conversions measured; never on two current rows in a cycle; no charter has twoNULL for the two filings with no Profile block. NCUA's own id: it has been seen to change once (SPIKE-002 §1.2)
rssd_numberprofilesjoining to Federal Reserve dataon 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_date was DATE and is now TIMESTAMP: 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.

version and status are 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 a submission_id. An amendment is recognised as "a pair we already hold, now carrying a different submission_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), or bulk (backfill/cross-check, amendment-incomplete) | | status | VARCHAR | current or superseded — 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 on curated/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)

columntypenotes
quarterVARCHAR
cycle_dateDATE
cu_numberINTEGER
submission_idBIGINTthe winning filing only, like curated/accounts
account_codeVARCHARa code whose value did not parse as a number — five today: A700, A701, A876, ANW0001, A999 (the note in §3 above)
account_textVARCHARthe value exactly as filed, trimmed — Yes, Well Capitalized, …
ingested_atTIMESTAMP

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)

columntypenotes
quarterVARCHAR2026Q2 (also the Hive partition)
cuidINTEGERthe key — one row per cuid, so an institution that converted its charter is one row (Which key above)
cu_numberINTEGERfor display: the charter it filed under this quarter, or, if it has not, the one it filed under in Q−1
cu_nameVARCHARfor display, chosen like cu_number
outcomeVARCHARfiled, outstanding or excused
weight_assetsDECIMAL(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_ofVARCHARthe quarter weight_assets is from — the manifest's quorum.weights_from; NULL where weight_assets is
expected_basisVARCHARprior_cycle or partial, as on the quarter's quorum
cuid_basisVARCHARwhere 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/*.parquet and …/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 NFICU filers are "not in NCUA's public bulk files". They are — all 86, as CU_TYPE 3, with the same $23,718,765,117 of total assets [measured: call-report-data-2026-03.zip, FOICU.txt and FS220.txt]. What omits them is the engine's legacy bulk build, which filters to CU_TYPE 1 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 includes NFICU differs 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_number is a charter, cuid is the institution. A charter conversion mints a new cu_number; cuid is equal across every conversion measured. For anything over time, filter on cuid (§3 Which key, §4).
  • curated/profiles is one row per winning filing, keyed like curated/accounts, and most of it is the cycle's — but credit_union_name and profile_charter_number are as-of-fetch, and three columns are NULL before 2022Q4 (§3). It does not cover curated/accounts: join LEFT.
  • Deduped & newest-wins: curated/accounts and submissions with status='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 via superseded rows 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. The superseded rows 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/seed always supersedes bulk. A bulk-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.json first 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_cycle advances to it. Don't headline the figures of a quarter later than latest_bulk_cycle as 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). No certified_at is published. NCUA's Profile.CertificationDate, in curated/profiles as certification_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, spike 2026-09-26-quorum-policy@9924aa0]. So the earliest evidence of a filer's filing is min(validation_date) over its rows in index/submissions.parquet, superseded ones included — and for a filing amended before callbase held it, that is the amendment's. No reached_at is derived from it yet; every reached_at today is observed (§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_code for a CU means "not reported," not 0. A present row with a NULL account_value means published as text when curated/accounts_text has 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) keep schema_version major, bump minor. Nothing a client already reads changes, so nothing a client already wrote breaks. First applied 2026-09-26: quorum_policies, quarters[].quorum and index/quorum make it 1.1, from the first manifest published with them. The additive fields and tables before them — index, accounts_text, profiles, cuid, among others — shipped under 1.0 without a bump; the changelog says so. A client that read 1.0 reads 1.1 unchanged. Applied again 2026-10-28: the digests, freshness.last_curate_run, inventory.catalog_at and quarters[].counts_at make it 1.2. 1.2 also carries a rule 1.1 did not: every path names its digest and its byte count. A client that read 1.1 reads 1.2 unchanged.
  • Breaking changes (column removed/retyped, layout change) bump the major schema_version and 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_version from the manifest and refuse a major they do not recognise, rather than assume forward compatibility. Minors are safe to ignore.

Contract schema 1.2 · docs built 2026-10-29 from platform 4894823