Skip to content

Architecture Decision Records

Format: context → decision → consequences. Status accepted unless noted. All decisions revisitable; changes append a superseding ADR, never silent edits.

ADR-001 — Dual-target warehouse: DuckDB default, PostgreSQL full profile

Context: Brief prefers PostgreSQL, allows DuckDB demo mode; demo must run from a clean clone without infrastructure. Decision: DuckDB is the zero-setup default (make demo); PostgreSQL runs via Docker Compose as the full profile; both targets exercised in CI. Consequences: SQL must stay dialect-portable; dbt adapters (ADR-002) absorb most differences; slight extra CI cost, large accessibility win.

ADR-002 — dbt-core for gold marts

Context: Brief allows dbt "where it materially improves warehouse modelling". Dual-target SQL, model tests, docs, and a dependency graph are exactly that case. Decision: bronze→silver transformations that need Python (parsing, entity resolution) live in the Python package; silver→gold marts live in dbt with dbt tests. Consequences: one more tool in the chain, but lineage graph, portability, and tested SQL come nearly free; dbt docs feed the data dictionary.

Amendment 2026-07-28 (Phase 2): dbt-core is not installable in the build environment (PyPI blocked). Gold marts are implemented as ordered, tested SQL models (ingoshift.warehouse.MARTS) — the brief's explicitly allowed alternative. dbt migration stays on the table for a network-enabled environment; the SQL is already dialect-portable to ease it.

ADR-003 — Prefect 3 over Dagster/Airflow

Context: Orchestration must not dominate a demo-sized system; brief excludes Airflow without documented need. Decision: Prefect 3, flows runnable in-process (ingoshift run-all works without any server). Consequences: lineage is not delegated to the orchestrator — it lives in our own fact_source_run / fact_change_event tables, keeping the honesty story independent of tooling.

Amendment 2026-07-28 (Phase 2): Prefect is not installable in the build environment. Pipeline steps are plain, flow-shaped functions (ingoshift.pipeline) run by the CLI; the Prefect wrapper is added when an index-reachable environment exists. Because run tracking lives in fact_source_run, nothing about lineage or reproducibility waits on the orchestrator.

ADR-004 — Streamlit + Plotly as the one public dashboard

Context: Brief demands one polished dashboard over frontend complexity. Decision: Streamlit multipage app; Plotly charts; every page carries the source/coverage/quality banner component. Consequences: fastest path to a deployable, screenshot-able product; a React frontend remains possible later since all data flows through the FastAPI layer anyway.

Amendment 2026-07-29 (post-review): The deployable public product is a static, self-contained HTML dashboard generated from the warehouse (ingoshift dashboard). It uses inline CSS/SVG plus a small inline script for text and NUTS filtering; there are no external runtime resources. Company profiles and tender/source details use native HTML disclosure elements. The file is hostable on GitHub Pages or a file share and clearly labels its data classes. Byte identity is guaranteed when rendering the same stored warehouse; a new pipeline run intentionally has a new run ID and retrieval timestamps. Streamlit is no longer a prerequisite. An API-backed app is optional future work only when a client needs server-side refresh or access control.

Amendment 2026-07-30 (portfolio UI): The dashboard adopts a product-style shell with compact navigation, editorial hierarchy, responsive cards, and reduced technical noise above the fold. English remains the international portfolio default; users can switch the entire interface to German. German presentation text uses native Unicode umlauts and ß, while immutable source values remain unchanged in storage.

ADR-005 — Quality: Pandera schemas + internal severity rules engine

Context: Great Expectations/Soda add weight; the brief requires critical/warning/info severities and a publication-blocking gate. Decision: Pandera for frame/schema contracts at layer boundaries; a small internal rules engine for cross-record, semantic, freshness, and anomaly rules; all results persisted to fact_data_quality_run; critical failure = non-zero exit = no publish. Consequences: full control and transparency; we own the rule DSL (kept tiny).

ADR-006 — Tooling: uv + lockfile, Typer CLI, Ruff, mypy, pytest

Decision: Python 3.12, uv for locked reproducible envs, Typer for the CLI, pre-commit with Ruff (format+lint), mypy strict-ish on core packages, pytest with per-package coverage thresholds. Consequences: standard, boring, portable.

Amendment 2026-07-28 (Phase 1): baseline lowered to Python ≥3.11 (build environment ships 3.11; CI covers 3.11 and 3.12). CLI implemented with click instead of Typer: PyPI was unreachable from the build sandbox, click was available, and at this CLI size Typer adds only sugar — one dependency saved. Revisit only if the CLI grows complex option models. The uv lockfile is deferred until an index-reachable environment can resolve it (tracked in backlog F-01 note).

Amendment 2026-07-28 (Phase 2, ADR-001 scope): DuckDB is likewise not installable here; the warehouse v1 runs on SQLite behind a thin wrapper (ingoshift.warehouse), with dialect-portable SQL (ISO-TEXT dates, no autoincrement, no backend-specific date functions). The active backend string is printed in every run-all/status output. DuckDB (demo default) and PostgreSQL (full profile) are restored as soon as an environment can install them; the swap is contained in one module by design.

ADR-007 — Language and licensing

Decision: Code, docs, and dashboard UI in English; German source data preserved verbatim (skills/labels keep original strings alongside canonical forms). MIT for code; per-source data licenses documented and enforced via dim_source attribution fields; raw data never redistributed. Consequences: portfolio reaches international clients; regional authenticity preserved in the data itself.

ADR-008 — Posting-level job data is flag-gated; official aggregates are the real signal

Context: BA offers no official postings API; the bund.dev wrapper is reverse-engineered with no stated terms (verified 2026-07-28). Decision: the public demo's real labour-market data = official BA + GENESIS aggregates. The posting-level extraction pipeline (skills NLP, dedup, co-occurrence) is built and tested against clearly-labelled synthetic/fixture postings. The wrapper client may exist behind INGOSHIFT_ENABLE_UNOFFICIAL_SOURCES=true with a terms warning, off by default, excluded from published outputs. Consequences: demo stays legally clean; skills module is honest about data class; if official terms appear, the switch is one flag plus a source-catalogue update.

ADR-009 — Company universe: curated seed + open identifiers, no registry scraping

Context: No free, legal, current bulk registry exists (Handelsregister restricted; OffeneRegister dump is 2019-vintage). Decision: company dimension starts from a curated seed (~150–250 Region-10 organizations) built from public self-published information, Wikidata QIDs where available, and evidence accumulated from tenders/research/jobs records; every field carries source evidence; OffeneRegister dump used only as a labelled fixture for entity-resolution tests. Consequences: smaller N, stated coverage, zero legal exposure; the auditable- evidence model becomes a selling point rather than a workaround.

ADR-010 — Conservative, auditable entity resolution from day one

Decision: deterministic normalization (legal-form stripping, umlaut/case folding) + exact keys (domain, QID) + fuzzy matching only above conservative thresholds; below threshold → review queue, never auto-merge. Tables company_entity_map, company_merge_log, company_manual_overrides from the first schema version. Consequences: fewer merges, no silent wrong merges; every merge explainable in the dashboard.

ADR-012 — Extraction layering: dictionaries first, models only when justified

Context (2026-07-28, Phase 3): the brief mandates a layered extraction strategy (deterministic → structured metadata → NLP → optional flagged LLM → review queue) and forbids a mandatory LLM. Decision: layer 1 ships now: versioned taxonomy variants matched case-insensitively on word boundaries; observed strings retained on the bridge; method recorded per mention; seniority/occupation inference labelled via *_method. Layers 2+ (spaCy model, flagged LLM assist, review queue) are added only with a written value justification once real posting text exists — on synthetic fixtures a model would only demonstrate overfitting to our own inventions. Consequences: under-extraction is possible (inflections, synonyms) and is made visible by the JOB-W1 coverage warning; negation-blindness is documented in limitations.

ADR-013 — Component indicators instead of a composite "Transformation Index"

Context (2026-07-28, Phase 4): the brief permits a composite index only with published formula, component weights, inspectability, and sensitivity analysis, and demands interpretable indicators. Decision: publish component-level indicators only (mart_transformation_indicators: per theme — companies_total, companies_current, postings_linked; all 13 themes always present, including zeros). No weighted score anywhere. Consequences: users compare themes on stated facts; if a client ever needs a composite, it is built per the brief's full transparency requirements as new, separate work.

ADR-011 — Raw layer: immutable hashed Parquet/files partitioned by source and date

Decision: every collector writes raw responses verbatim (plus fetch metadata: URL, timestamp, status, content hash, parser version) to data/raw/<source>/<yyyy-mm-dd>/; bronze parses raw without re-fetching; replays are deterministic. Consequences: change detection and audits are possible years later; storage is cheap, trust is not.