Foundational knowledge

What is an ETF?

An ETF is a regulated fund that holds a portfolio (stocks, bonds, commodities, crypto, etc.) and issues shares listed on exchanges. Corgi is the issuer; investors hold fund shares, not direct ownership of every underlying stock.

Two markets — primary vs secondary

ETF activity splits into two markets. Secondary is investors buying and selling existing ETF shares on exchanges — this is the vast majority of volume. Primary is Authorized Participants (APs) creating or redeeming shares directly with the fund — this changes shares outstanding and AUM, but happens far less often.

pie showData title US ETF activity "Secondary" : 90 "Primary" : 10

US average · ICI

pie showData title Europe ETF activity "Secondary" : 67 "Primary" : 33

Europe approx. · ~2:1 sec:prim

Primary market
~10% US · ~33% Europe
WhoAuthorized Participants — banks / broker-dealers only
ActionCreate or redeem creation units with the fund
SystemFund accounting · flow ledger
ResultShares outstanding + AUM change · Flow columns in report
Secondary market
~90% US · ~67% Europe
WhoInvestors — retail, institutions, market makers
ActionBuy or sell existing ETF shares on an exchange
WhereLSE · Xetra · Euronext · NYSE Arca…
ResultOwnership changes · fund portfolio unchanged · Turnover columns in report

Architecture — today vs target

Five fixed roles — only the company inside each box changes. Fund accounting always pulls market data and operational truth into a fund administrator, which produces NAV and GL for the issuer.

Roles

RoleResponsibilityAccountability
Issuer / sponsorETF issuer; consumes NAV, GL, and regulatory outputsOwns the fund; does not run daily accounting
Fund administratorNAV engine, GL, trial balance, fee accruals, corp-action application, EOD batchReconciles all inputs; signs off NAV before publish
CustodianSafekeeping of fund assets; holdings, cash, settlement, trade confirmsWarrants accuracy of custody records under custody agreement
Market data vendorEOD prices, FX, corporate actions, security reference, venue turnoverVendor SLA on data quality; admin reconciles before use
Transfer agentShare registry; creations / redemptions processing; shares outstandingShare-count accuracy; feeds operational truth into admin
Removing U.S. Bank as fund admin ≠ removing U.S. Bank entirely. The target diagram assumes Corgi takes administration only. U.S. Bank stays as custodian under a separate contract — they still hold the assets and must still deliver holdings / cash / trade files to whoever runs NAV. That feed does not depend on GFS being your admin; it depends on the custody agreement. If custodian data is wrong, the custodian is liable under that contract. If Corgi admin publishes a bad NAV without catching it in reconciliation, the administrator is liable. Daily recs exist to catch errors before sign-off. To remove U.S. Bank completely would require a custody migration (e.g. BNY, State Street) — a separate, larger project than bringing admin in-house.
Today
U.S. Bank GFS = fund admin + transfer agent
flowchart TB subgraph md["Market data vendor"] MD["Bloomberg · ICE backup"] end subgraph cust["Custodian"] CU["U.S. Bank"] end subgraph ta["Transfer agent"] TR["U.S. Bank GFS"] end subgraph admin["Fund administrator"] FA["U.S. Bank GFS"] end subgraph iss["Issuer / sponsor"] IS["Corgi"] end MD -->|"Datasets 1–3, 5"| FA CU -->|"Dataset 4 — holdings, cash, trades"| FA TR -->|"Shares outstanding, C/R processing"| FA FA -->|"NAV, GL, trial balance"| IS
Target
Corgi = fund admin + transfer agent · U.S. Bank = custodian only
flowchart TB subgraph md["Market data vendor"] MD["Bloomberg · ICE backup"] end subgraph cust["Custodian"] CU["U.S. Bank"] end subgraph ta["Transfer agent"] TR["Corgi in-house"] end subgraph admin["Fund administrator"] FA["Corgi in-house"] end subgraph iss["Issuer / sponsor"] IS["Corgi"] end MD -->|"Datasets 1–3, 5"| FA CU -->|"Dataset 4 — holdings, cash, trades"| FA TR -->|"Shares outstanding, C/R processing"| FA FA -->|"NAV, GL, trial balance"| IS

What changes: fund administrator and transfer agent move from U.S. Bank GFS to Corgi in-house. Custodian and market data vendors stay on existing contracts. Bloomberg / ICE feed the admin's pricing pipelines — not the custodian. Custodian data is a separate input (dataset 4) governed by the custody SLA, not the admin outsourcing agreement.

Data sourcing

Fund accounting requires six input datasets — five core plus product-specific add-ons. See architecture for how market data vendors, custodian, and administrator roles map to these inputs. Market data (datasets 1–3, 5–6) typically comes from one vendor contract (Bloomberg below); operational data (dataset 4) originates at the custodian and is consumed by the fund administrator. Each pipeline lists one primary, one backup, and one cross-check vendor.

#DatasetWhat it providesPrimaryBackupCross-check
1 Pricing & FX Equity EOD marks, fixed income clean/dirty prices, FX spot rates, price quality flags Bloomberg ICE LSEG
2 Corporate actions & FI events Dividends, splits, mergers, bond calls, maturities, coupon changes, partial redemptions Bloomberg ICE LSEG
3 Security reference ISIN/CUSIP/SEDOL, bond coupon/maturity/day-count, asset class, withholding rates Bloomberg SECF ICE Ref Data LSEG
4 Operational truth Custodian holdings & cash, trade history, shares outstanding, AP create/redeem confirms U.S. Bank Custody FundGuard In-house ledger
5 Venue turnover Per-ISIN secondary-market turnover by listing venue (LSE, Xetra, Euronext…) Bloomberg ECP LSEG Desk feed
6 Crypto spot Spot bid/ask/last for direct crypto holdings — only if fund holds crypto Kraken Coinbase

Other product-specific add-ons: derivatives marks (2×/buffer funds), index/benchmark levels (buffer funds). Internal fund master and fee schedules are configuration — enriched on top of dataset 3, not a separate vendor pipeline.

Procurement paths

Two providers in practice — (1) one market data contract (Bloomberg Data License covers datasets 1–3 and 5; ICE as backup); (2) custodian feed for dataset 4, consumed by the fund administrator. Vendor choice is procurement — confirm existing Corgi contracts before assigning primaries.
Administrator pass-through — published per-security rates when market data is sourced through the fund administrator. Source: U.S. Bank fund servicing fee schedule (SEC filing, 2025). Thematic equity fund (~100 holdings): ~$5K–$6K/year market data. Fixed-income fund (~200 bonds): ~$28K–$40K/year — FI pricing dominates.

Redundancy rules (market-data pipelines)

PipelineReconciliation rule
Equity priceBlock NAV if primary vs backup differ by more than tolerance (bps)
FI priceEvaluated marks — wider tolerance; flag illiquid names
Corp actionsDo not apply until 2-of-3 vendors agree on ex-date and rate
FXExact match expected on WM/Reuters fix

Datapoints

Every line on the fund general ledger and trial balance maps to a datapoint below. Reference GL: EUV thematic equity fund (211161). Bond funds (e.g. 211265) add fixed-income lines. Groups reflect Corgi product type — not GL sub-category codes.

Click any row to expand the description and formula. One source of truth — no separate detail section.

Field prefixes: D1 pricing & FX · D2 corp actions & FI events · D3 security reference · D4 operational truth · CFG fund config (fee schedule, base currency)
Method: Pulled = maps 1:1 to a single normalized input field · Computed = combines two or more inputs via an explicit formula

GroupDatapointMethod

Internal fields

Not everything comes from a vendor feed or a formula. Corgi must store and maintain fund-specific configuration and identifiers — the fund master. Without these, datapoint computes have nothing to key on.

FieldWhy we store itExample
Fund tickerMaps vendor security IDs to Corgi product; used in reports and basketsDNRO, EUV
Fund legal nameRegulatory filings, investor communicationsCorgi Global X … ETF
Share classMulti-class funds; fee and NAV per classClass A / institutional
Fund share ISIN / CUSIPETF share identifier — distinct from underlying holdingsUS… / XS…
Base currencyFX conversion anchor for trial balanceUSD, EUR
Fee schedule (bps)Unitary fee accrual — no vendor supplies this20 bps on net assets
Fiscal year-endPeriod close, annual audit boundaryDec 31
Authorized participantsCreate/redeem settlement and breakage rulesAP bank list
Listing venuesTurnover report venue columnsLSE, Xetra, NYSE Arca
GL chart mappingDatapoint → GL account code211161 EUV mapping
Recon tolerancesValidation thresholds (bps, absolute)Equity price ±5 bps
NAV cutoff timeEOD batch schedule per market4:00 PM ET equity close
Settlement calendarT+1 US / T+2 EU rules per marketUS equity T+1; EU T+2
Distribution groupsEmail recipients per report typedaily_volume_report → desk + issuer

Stored in CFG.* referenced throughout datapoint formulas. Changes are versioned and effective-dated — not overwritten silently.

Data pipelines

Receiving data is the easy part — a vendor CSV drop over SFTP/FTP. The hard part is when files arrive, what you store, how you validate before normalization, and how you prove it later. Everything below assumes we persist data; we do not compute NAV from live API calls at publish time.

Why end-of-day?

Fund accounting runs on an as-of date, not in real time. A trade executed today does not settle today — US equities are T+1 (next business day); EU markets are typically T+2. Until settlement, the position is in investment receivable/payable, not cash. Market data vendors publish official EOD marks only after the close. Running the batch intraday would mean marking on incomplete prices and booking unsettled trades as settled.

EOD batch logic: wait for (1) market close + official prices, (2) custodian day-end file, (3) corp-action confirmations — then compute, reconcile, sign off, publish.

flowchart LR subgraph intraday["Intraday"] T["Trade executed"] end subgraph eod["EOD batch (as-of date)"] P["Prices available"] C["Custodian file"] R["Reconcile 3 sources"] N["Compute NAV"] S["Sign-off & publish"] end subgraph later["Later"] SET["T+1 / T+2 settlement hits cash"] end T --> P P --> R C --> R R --> N --> S T -.-> SET

Ingest & compute (AWS)

Feeds arrive as CSV exports pushed by vendors (and U.S. Bank GFS during calibration) over SFTP or FTP. AWS pattern: Transfer Family exposes the endpoint → files land in an S3 landing prefix → Lambda fires on ObjectCreated.

CSV ingest
SFTP/FTP drop · validate before normalize
flowchart TB subgraph vendor["Vendor / U.S. Bank GFS"] CSV["CSV export"] end subgraph xfer["AWS Transfer Family"] SFTP["SFTP / FTP"] end subgraph landing["S3 landing"] IN["incoming/{feed}/"] end subgraph lambda["Lambda · on upload"] VAL["validate CSV contract"] PARSE["parse → core.*"] end subgraph archive["S3 archive"] ARC["archive/{vendor}/{feed}/{as_of}/"] end subgraph outcomes[" "] OK["core.* row insert"] Q["quarantine + alert"] end CSV --> SFTP --> IN --> VAL VAL -->|pass| ARC VAL -->|pass| PARSE --> OK VAL -->|fail| Q

Lambda does two things on every file:

  1. Archive — move CSV to immutable S3 hierarchy: archive/{vendor}/{feed}/{as_of_date}/{received_at}_{filename}. This is the raw layer — we do not need a separate JSONB extract table when the vendor artifact is already a file.
  2. Normalize — parse validated rows into typed core.* tables. Idempotent per (as_of, feed, row_key) so reprocessing a file replaces the same logical rows, never silently duplicates.

Downstream EOD (ledger, reports, email) chains via EventBridge once required feeds for an as_of are validated + loaded. CloudFront Functions are edge HTTP only; batch work runs on Lambda in-region.

Ownership split. Engineering owns ingest → validate → normalize (core.*). Carl Clements owns ledger and fund accounting principles — datapoint formulas, GL logic, NAV compute from normalized inputs onward. The handoff is a clean boundary: normalized tables in, ledger out.

CSV validation — before normalized insert

Skipping validation and jumping straight to core.* is the main ingest failure mode. If our parser is wrong, we only find out when NAV breaks — too late. Validate before insert; on failure, quarantine the file, log to reporting.ingest_exceptions, alert ops, and block ledger for that feed/as_of.

FailureTypical causeGate
Schema driftNew, renamed, or missing column; column reorderStrict column contract per feed (versioned). Unknown column → block. Missing required column → block.
Format changeDate pattern flip, decimal separator, quoted-field breakage, encoding shiftTyped parsers with explicit formats; reject rows that fail cast.
Bad / empty dataNulls in required fields, negative qty where impossible, duplicate keysRow-level rules + aggregate sanity (row count vs prior day, sum checks).
Wrong fileBad as_of in filename, duplicate upload, truncated/partial fileFilename pattern match; file-size floor; checksum; dedupe on (feed, as_of, checksum).
Late or missing fileSFTP push delayed, vendor outageEventBridge cutover timer; missing required feed → block EOD, alert.
Parser bug (ours)Mapping typo, wrong delimiter assumptionImmutable S3 archive lets us replay; dev calibration diffs catch systematic errors.

Each feed ships with a CSV contract in repo config: expected columns, types, required/nullable, filename pattern, and schema version. Vendor changes the file → contract version bumps → parser update → replay from archive. Never mutate a archived CSV.

Cross-day sanity tests

After schema validation, compare today's file against T−1 loaded data. These are cheap catches for stale or broken drops — run on every feed before core.* insert.

TestWhat it catchesTypical response
Identical to yesterdayVendor re-sent stale file; as_of wrong inside fileBlock · quarantine · alert (your example — check all key metrics)
Row count collapseTruncated export, empty file, parser read wrong sheetBlock if count < floor or >50% drop vs T−1 without explanation
Row count spikeDuplicate rows, wrong fund rolled in, delimiter breakBlock if >2× T−1 without corp-action / trade event flag
All prices unchangedStale marks on an equity pricing fileBlock pricing feed when 100% of liquid names match T−1
Holdings qty unchangedStale custodian positions after known trading dayWarn or block when trades exist in confirms but holdings file is flat
Cash / NAV flatCustodian or TA file not refreshedBlock when aggregate cash, shares outstanding, or fund-level NAV equals T−1 on active day
Filename vs content as_ofWrong file in right folderBlock on mismatch between filename date and max date in rows
Checksum duplicateSame bytes uploaded twiceSkip second file; log as duplicate, do not re-load
Outlier moveSingle bad price or qty typoFlag rows where Δ vs T−1 exceeds CFG tolerance (e.g. price >20%, qty >50%)

Thresholds live in CFG.recon_tolerances per feed — not hardcoded. Log every failed test to reporting.ingest_exceptions with T and T−1 aggregates for ops review.

SFTP re-pull on stale or failed file

If cross-day or schema checks fail — file identical to yesterday, wrong as_of, truncated drop, etc. — do not load into core.*. Instead:

  1. Quarantine the file and log the failure reason
  2. Trigger an SFTP re-pull for that feed (Lambda polls vendor outbox or requests re-push per vendor SLA)
  3. Re-run validation on the new file; cap retries (e.g. 3 attempts before escalating)
  4. If still bad at EOD cutover → block ledger for that as_of and alert ops

Re-pull only applies when the CSV itself is suspect — not when our parser is wrong. Parser bugs are fixed in code and replayed from the S3 archive.

Ledger output checks (before GL insert)

Second failure point: bad calculations, not bad CSV. EventBridge invokes ledger Lambda after core.* is loaded. Before writing to ledger.*, sanity-check computed values against CFG.recon_tolerances — NAV delta vs T−1, GL line bounds, impossible negatives. Values outside the realm of possibility → block insert, log exception, do not publish.

Index and market-data providers (e.g. MSCI, Bloomberg) set the pattern fund admins mirror:

Our ingest methodology should be indistinguishable from how a Bloomberg or MSCI client would wire a fund admin — modular feeds, strict contracts, archived raw files, gated normalize.

Ingest metadata

Lightweight ingest.files table — not a second copy of the data:

feed, vendor, as_of_date, s3_key, received_at, checksum, schema_version, row_count, status (validated | quarantined | loaded)

Storage model

Four layers — raw is the CSV file on S3, not a parsed duplicate in Postgres.

LayerWhereContentsOwner
Raws3://archive/ + ingest.filesImmutable CSV as received; metadata row per file (checksum, status, schema version)Engineering
Normalizedcore.*Typed columns: price_eod, holdings_qty, corp_action… Parsed only after CSV validation passesEngineering
Ledgerledger.*Datapoint values, GL lines, NAV — produced from core.* + CFG.*Carl Clements
Recon & reportingreporting.*Ingest exceptions, calibration diffs (dev), vendor scorecard (prod). Does not block writes but gates EOD sign-offShared
Report archives3://reports/Rendered HTML (daily volume report, future packs)Engineering

Encryption: TLS in transit; AES-256 at rest (cloud KMS or on-prem HSM). Fund accounting data is not PCI, but it is fiduciary — treat as confidential. On-prem is viable for latency/control; cloud is viable for ops cost. Either works if access is role-scoped and audit-logged.

Validation — development vs production

Two phases. Do not conflate them.

Development (pre-launch calibration)

While building the in-house engine, the trusted reference is U.S. Bank GFS output — labeled ground truth from the system we are replacing. Each EOD:

  1. Run our best-effort datapoint functions on the same inputs
  2. Import U.S. Bank trial balance / NAV extract for the same as_of
  3. Diff every GL line and datapoint — log to reporting.calibration_diffs
  4. Where we disagree, backsolve: trace inputs → formula → find the break
  5. Fix compute, rerun until output is congruent

After several days of this loop, calculations should match on all standard paths. Remaining gaps are edge cases — corp-action quirks, FI amortization corners — where domain review (operations team) closes the last mile before we hit them in production.

Calibration loop
Same inputs · parallel compute · diff until congruent
flowchart TB subgraph inputs["Inputs · same as_of"] IN["D1–D4"] end subgraph usb["U.S. Bank"] USB["trial balance / NAV"] end subgraph corgi["Corgi compute"] CI["datapoint formulas"] end subgraph cmp["Compare"] DIFF["line-by-line diff"] end OK["congruent"] FIX["trace · tweak formulas"] IN --> USB IN --> CI USB --> DIFF CI --> DIFF DIFF -->|congruent| OK DIFF -->|mismatch| FIX FIX -->|"rerun"| CI

Production (post-launch vendor reconciliation)

Once live, U.S. Bank is no longer the reference — three market-data vendors per pipeline are. When primary (A) ≠ backup (B), cross-check (C) breaks the tie.

flowchart TD A["Primary (A)"] --> CMP{"A = B?"} B["Backup (B)"] --> CMP CMP -->|Yes| OK["Use consensus value"] CMP -->|No| C["Cross-check (C)"] C --> V{"A = C?"} V -->|Yes| FLAG_B["Flag B · use A · log exception"] V -->|No| W{"B = C?"} W -->|Yes| FLAG_A["Flag A · use B · log exception"] W -->|No| ESC["Escalate — block NAV · manual review"]

Every exception writes to reporting.vendor_exceptions: field, as_of, vendor flagged, values from A/B/C, resolution. Over time this becomes a vendor reliability scorecard. Three sources are sufficient for tiebreak — if A, B, and C all disagree, that is a block-NAV event.

Transform

core.*ledger.* via datapoint formulas (detail cards) — owned by Carl. Transforms are deterministic, versioned, and replayable from archived CSV + normalized rows. No ledger run for an as_of until required feeds show status = loaded in ingest.files.

Retention

SEC Rule 204-2 requires investment advisers to retain books and records for at least five years (first two in primary office). NAV history, GL, reconciliation logs, and vendor CSV archives qualify. Design default: 7 years online on S3, then glacier — covers examination cycles with margin. Immutable CSV archive makes replay and audit defensible without a parallel JSONB store.

Fund administration workflows

Industry-standard daily and periodic processes the system must support — not exotic, but all mandatory for a regulated ETF administrator.

WorkflowFrequencyTrigger → outcome
EOD NAV batchDailyFeeds arrive → ingest → validate → compute datapoints → reconcile custodian → sign-off → publish official NAV
Trade & settlementDailyTrade date books receivable/payable; settlement date (T+1/T+2) moves cash and clears bridge accounts
Corporate actionsEvent-drivenVendor alert → 2-of-3 confirm → adjust qty/cost/income → post GL entries
AP create / redeemAs occurredTA confirm → basket vs holdings rec → settlement → update shares outstanding and capital
Fee accrualDailyCFG.fee_bps × net_assets → accrued unitary fee; periodic payment clears accrual
Calibration (dev)Daily until cutoverCorgi compute vs U.S. Bank reference → diff → backsolve → fix until congruent
Reconciliation (prod)DailyCustodian vs internal; vendor A vs B vs C; all breaks cleared before NAV sign-off
Exception resolutionAs neededPricing override, corp-action confirm, manual JE — audit trail required
Month / quarter / year-end closePeriodicAccrual true-ups, financial statement pack, audit support file
sequenceDiagram participant V as Vendor CSV (SFTP) participant I as Ingest + validate participant C as core.* tables participant L as Ledger (Carl) participant R as Recon / reporting participant A as Accountant participant P as Publish V->>I: CSV drop to S3 landing I->>I: Contract check · quarantine on fail I->>C: Normalized rows (on pass) C->>L: core.* ready for as_of L->>R: Price / custodian compare R-->>A: Exception queue (if any) A->>L: Approve / override L->>L: Compute datapoints + NAV L->>A: Trial balance for sign-off A->>P: Publish NAV + outputs

Major outputs

Established artifacts every fund administrator produces — regulatory, operational, and investor-facing. These are not optional nice-to-haves; they are what auditors, the SEC, APs, and the issuer expect.

OutputAudienceFrequencySource
Official NAV / NAV per shareTA, exchanges, issuer, APsDailyComputed ledger · signed off
Trial balance / general ledgerInternal, audit, issuerDailyAll datapoint GL lines
Portfolio valuationIssuer, complianceDailyHoldings × marks + accruals
Schedule of investmentsSEC (N-PORT), annual reportQuarterly / annualHoldings snapshot
Creation / redemption basket fileAuthorized participantsDaily + on demandHoldings + cash component for CU
Daily volume / flow reportCorgi issuer, distributionDailyDataset 5 + Dataset 4 · see Daily report
Reconciliation & exception reportOperations, vendor managementDailyreporting.vendor_exceptions
SEC regulatory filingsSECPeriodicN-CEN, N-PORT, financial statements
Audit support packageExternal auditorAnnualGL, NAV history, S3 CSV archive replay, rec sign-offs
Investor factsheet / performanceInvestors, marketingMonthlyNAV series, AUM, benchmark

Daily fund report

Corgi runs a Daily Volume Report every business day — all funds on one HTML page. Tracks AUM, primary-market flow (creates / redeems), secondary-market turnover by venue, and NAV deltas. This is an issuer-facing operations report, distinct from the official NAV publish.

Report structure

Matches the provided sample (daily_volume_report_anonymised_.html). Skeleton generator: daily_volume_report.py.

PanelContents
Daily AUM changePer-fund flow (create/redeem), flow vs price decomposition, BOD/EOD AUM
YTD AUM changeGross creates, gross redeems, flow + price attribution, YoY AUM
Per-fund detailTicker, passive/active, inception, name, TER, theme, AUM, flow (1D/T+1/30D/YTD), venue turnover columns (LSE, Xetra, Euronext…), NAV per share + delta %

Flow columns = primary market (AP creates/redeems). Turnover columns = secondary market volume by listing venue (Dataset 5).

Generation pipeline (AWS)

flowchart LR EOD["EOD batch complete"] --> EB["EventBridge schedule"] EB --> L["Lambda: render report"] L --> DB["ledger + core + dataset 5"] L --> S3["S3 reports/daily/{date}.html"] S3 --> SES["SES email · HTML embed"] SES --> DG["CFG.distribution_groups"]
StepAWS serviceDetail
TriggerEventBridgeFires after EOD NAV sign-off (or fixed cutover time)
RenderLambdaQuery reporting DB → build HTML from template → all Corgi funds
StoreS3Immutable archive: reports/daily_volume/{YYYY-MM-DD}.html — full history retained
DistributeSESHTML body embedded in email to recipients in CFG.distribution_groups
AccessCloudFront + S3Optional: serve historical reports via CDN for internal dashboard

Distribution groups stored in fund config — not hardcoded. Example: daily_volume_report → desk, issuer, distribution list. AWS SES identity and suppressions managed in-console; recipient lists live in our DB so ops can update without redeploying Lambda.

Sophisticated report packs (monthly performance, audit extracts) follow the same pattern: Lambda render → S3 archive → SES to a different distribution group. Detail deferred — same infrastructure.

The HTML report is a distribution output (email + archive) — not the main UI. Day-to-day work happens in the web app.

UI

The most critical feature is calculating reliable figures — eliminating dependence on U.S. Bank as fund administrator. A polished UI is secondary but matters for selling the system to other firms later.

Web first — faster iteration, easier demos to prospective clients. The daily volume HTML report is a distribution artifact we generate and send; the web app is where accountants work.

Specific screens and layout TBD — validate by shadowing the current U.S. Bank workflow once we have access. Desktop shell (Electron) only if a buyer requires on-prem deployment.

Implementation plan