Skip to content

feat: индекс на качеството на договорите (ETL оценка 0..1 + страница) - #188

Open
StanislavBG wants to merge 40 commits into
midt-bg:mainfrom
StanislavBG:feat/contract-health-index
Open

feat: индекс на качеството на договорите (ETL оценка 0..1 + страница)#188
StanislavBG wants to merge 40 commits into
midt-bg:mainfrom
StanislavBG:feat/contract-health-index

Conversation

@StanislavBG

@StanislavBG StanislavBG commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

What changed

Contract quality index (ETL score 0..1 + /quality page). Latest round addresses ydimitrof's 2026-07-13 review (9 threads):

  • scripts/import.mjs: runFullDerive/runSliceDerive now gate on checkContractFeaturesIntegrity in addition to the standard CHECKS, so the local full/slice/health ETL path enforces the same contract_features invariants (row parity, A1 floor, score_b=0 on direct award, no value_suspect leakage) as ship-domain.mjs.
  • scripts/precompute.sql: dropped the COALESCE(tenders.published_at, date('now')) FX-rate fallback — a NULL published_at now resolves estimated_value_eur to NULL instead of silently borrowing a same-day rate that could mis-date historical foreign-currency estimates.
  • scripts/validate-health.mjs: fixed a false-FAIL where the synthetic 'NA' bucket (undated/out-of-range contracts) tripped the >60%-NULL threshold check meant only for 2020–2026 strata; and fixed the Spearman-lite decile-correlation check printing NaN on zero-variance samples (now logs and skips).
  • apps/web/app/lib/trendAxis.ts: removed the in-code TODO(#170) note — the third-copy consolidation is tracked solely via issue feat(web): договори — обзор (лещи време/CPV/кръстосано) #170 per repo convention.
  • Confirmed (no code change needed): filters.ts ranking params (rdir/rfrom/rto) are fully parameterized via .bind(...) in packages/db/src/queries/quality.ts (no string interpolation); analytics.tsx's getQualitySummary(...).catch(...) correctly no-ops on the expected pre-derive "no such table" case; migration 0003_contract_health.sql's out-of-numeric-order columns are purely additive and independent of the still-pending 0002_contracts_overrun_index, and all target environments apply via wrangler d1 migrations apply (tracked by name, not raw re-execution); the ship-domain.mjs non-atomic DROP/CREATE availability window on contract_features is a known, already-documented follow-up (staging-table swap), out of scope here.

How it was tested

  • pnpm --filter web test and pnpm --filter db test — both pass, including regression coverage added for the axis/relLabel edge cases and the schema-drift guard extension from the prior round.
  • A node:sqlite fixture trace re-confirmed the validate-health.mjs NA-bucket and zero-variance fixes against a synthetic dataset.

Quality checks

  • CI green on the current head commit.
  • ETL two-path parity preserved: none of this round's fixes touch identity/dedup/keying logic shared between normalize-raw.sql and refresh-slice.sql.
  • All 9 review threads from the 2026-07-13 round verified against the landed diff and resolved.

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review на feat/contract-health-index (@ 4796a75) — пуснах миграциите и health derive-а локално (node:sqlite), не само четох diff-а.

Два блокера, и двата възпроизведени локално:

  1. Свежо wrangler d1 migrations apply пада — виж inline на 0002_contract_health.sql.
  2. derive-health.sql аборти́ра целият при CPV дивизия със сума 0 — виж inline на derive-health.sql.

Потвърдено чисто (проверих, не са проблеми):

  • value_suspect: коректно NULL-gated — score_overall/score_c са NULL за value_suspect (derive-contract-features.sql:209,478,568), плюс self-check на ред 602 и validate-health.mjs т.2. Обратното на капана от #182 — тук инвариантът е верен и тестван.
  • cache-key drift: новите /quality параметри (contract, csort, grain, sel) са в CACHE_QUERY_PARAMS — няма CWE-349 дрифт.
  • Staleness: health derive-ът СЕ пуска след slice-а (import.mjs: runSliceDeriverunHealthDerive, и ship-domain.mjs след precompute) — rollup-ите не застояват. Проверих нарочно, защото refresh-slice.sql сам по себе си не ги пипа.

Извън обхвата на този преглед: не одитирах петте pillar формули в derive-contract-features.sql (795 реда, тегла/leaves) — заслужава отделен pass.

Присъда: промени преди merge (2 блокера). От Triage — не е формален approve/reject.

Comment thread packages/db/migrations/0002_contract_health.sql Outdated
Comment thread scripts/derive-health.sql
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Двата блокера са затворени, миграцията е преномерирана. Нов връх: 86f89d1.

  • Блокер 1 — свежо migrations apply (duplicate column)714aa45: деветте колони живеят САМО в миграцията (махнати от 0000_init.sql); rollup таблиците остават в 0000 и се (пре)създават идемпотентно от derive скриптовете за вече мигрирани бази. Двойната роля на 0000_init е запазена: import.mjs (work-db backfill) и sqlite тестовите фикстури вече прилагат ПЪЛНАТА верига по ред — точно както свежа D1. migrations.test.ts прилага цялата свежа верига и проверява колоните + таблиците (регресионният тест за блокера).
  • Преномериране → миграцията е 0003_contract_health.sql — 0002 остава за 0002_contracts_overrun_index (feat(web): договори — обзор (лещи време/CPV/кръстосано) #170/feat(web): overruns dashboard #171), по бележката за координация.
  • Блокер 2 — derive аборт при CPV дивизия със сума 02e1bef6: HAVING SUM(c.amount_eur) <> 0 в div_totals (само NULLIF не стига заради NOT NULL — по инлайн бележката). Нулевата дивизия се пропуска при източника; надолу (LEFT JOIN в derive-contract-features) договорите ѝ получават sector_win_share NULL — честно „няма данни", никога фабрикувана 0. Нов derive-health.test.ts с фикстура amount_eur=0 доказва, че derive завършва и дивизията няма ред.
  • Покрай зелените тестове: 4d456fc (SQLITE_LOCKED от голия диагностичен SELECT + компаунд лимита на локалната D1 в derive-contract-features) и 86f89d1 (typecheck: Crumb няма href).
  • Проверки: typecheck 7/7 ✓; db тестове 194/194 ✓; свежа верига 0000→0001→0003 приложена в чист sqlite без грешка ✓.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

@StanislavBG — не намирам 86f89d1 нито тук, нито във fork-а StanislavBG/sigma-pr; PR-ът още сочи 4796a75 (последният коммит в PR-а). Изглежда fix-ът не е push-нат. На текущия head двата блокера са още активни — ре-проверих локално:

  • Миграция: 0000_init.sql и 0002_contract_health.sql пак дублират деветте колони → свежо wrangler d1 migrations apply пада с duplicate column name: exemption_legal_basis (прилага 0000→0001→0002 по ред). Не е преномерирана на 0003.
  • derive-health.sql:113: win_share пак е SUM(c.amount_eur)/dt.total_eur без <> 0 гард, а колоната е NOT NULL → CPV дивизия със сума 0 аборти́ра целия derive.

Push-ни 86f89d1 към feat/contract-health-index и ще ре-верифицирам. Дотогава #188 не е за merge — внимание да не се слее по коментара „затворени", защото head-ът е още старият.

@ydimitrof

Copy link
Copy Markdown
Contributor

Both blockers reproduced locally at the PR head (4796a75). Security surface is clean. Writing the review.


Ревю на PR #188 — Индекс на качеството на договорите

Благодаря за сериозната работа — методологията е добре документирана, а ETL слоят е внимателно оформен. Прегледах не само diff-а, а възпроизведох миграциите и derive-а локално (SQLite) на текущия head на PR-а (4796a75). За съжаление и двата блокера, докладвани от @lyubomir-bozhinov, са все още активни — коментарът „затворени / нов връх 86f89d1" не отговаря на състоянието: 86f89d1 / 714aa45 / 2e1bef6 не са пушнати към feat/contract-health-index, head-ът е още старият. Затова прегледът стъпва върху това, което реално е в клона.

🔴 Блокер 1 — свежа миграционна верига пада (дублирани колони)

Деветте нови колони живеят едновременно в 0000_init.sql (inline) и в 0002_contract_health.sql (ALTER TABLE … ADD COLUMN). При чист wrangler d1 migrations apply редът е 0000 → 0001 → 0002, така че 0002 се блъска в колони, вече създадени от 0000.

Възпроизведено локално (чист SQLite, точната верига):

=== applying 0002_contract_health.sql ===
Parse error near line 7: duplicate column name: exemption_legal_basis
… outside_zop, dps_contract, reason, circumstances,
   corrections_count, estimated_value_eur, first_date, last_date

Всяка свежа инсталация на D1 ще пропадне. Съществуващият migrations.test.ts не улавя това — той прилага само 0000 и 0001 (не е пипнат в този PR), затова тестовете минават, а миграцията пак е счупена. Планираният регресионен тест (пълна верига до 0003) е в непушнатия комит.

Поправката, описана от автора (колоните само в миграцията + преномериране на 0003, за да не се сблъсква с 0002_contracts_overrun_index), е правилна — просто трябва да бъде push-ната.

🔴 Блокер 2 — derive-health.sql абортира при CPV дивизия със сумарна стойност 0

В div_totals няма гард HAVING SUM(c.amount_eur) <> 0, а win_share = SUM(c.amount_eur)/dt.total_eur се вписва в колона win_share REAL NOT NULL. Деление на нула в SQLite връща NULL → нарушение на NOT NULL → целият derive се къса. Сценарият е реалистичен: дивизия, в която положителни суми и отрицателни корекции/анулации се компенсират до 0.

Възпроизведено локално с фикстура (+100 / -100 в една дивизия):

Runtime error near line 9: NOT NULL constraint failed: sector_concentration.win_share (19)

Само NULLIF не е достатъчен (колоната е NOT NULL) — правилният подход е HAVING SUM(c.amount_eur) <> 0 в източника, за да се пропусне нулевата дивизия и договорите ѝ да получат честно NULL sector_win_share надолу по веригата. Точно каквото описва непушнатият fix.

✅ Сигурност / OWASP / цялостност на данните — чисто

Специално одитирах за инжекции и злонамерен код — не открих проблеми:

  • SQL инжекция (OWASP A03): query слоят е коректно параметризиран. grain/sort се валидират срещу whitelist-ове (GRAINS, qualityRanking), top е клампнат с Math.min(Math.floor(top), MAX_TOP), а sel винаги минава като bind-параметър (quality.ts:252-269). ORDER BY фрагментите са фиксирани литерали, не потребителски вход. Няма стринг-интерполация на недоверени стойности в SQL.
  • XSS (A03): quality.tsx разчита на escaping-а на React по подразбиране; няма dangerouslySetInnerHTML, innerHTML или eval.
  • Command injection: import.mjs ползва execFileSync/spawnSync с масиви от аргументи (без shell), пътищата към скриптовете са твърдо кодирани.
  • Тайни: няма хардкоднати ключове/пароли/токени в добавените файлове.
  • Cache-key drift (CWE-349): новите /quality параметри (contract, csort, grain, sel) са в CACHE_QUERY_PARAMS — потвърждавам наблюдението на @lyubomir-bozhinov.
  • Цялостност на данните: value_suspect е коректно NULL-gated (никога фабрикувана 0); health derive-ът се пуска след slice-а, така че rollup-ите не застояват.

Съответствие със спецификацията

Имплементацията следва методологията от docs/contract-quality-spec.local.md (5 стълба с описаните тегла, композит 0.6·среднопретеглено + 0.4·най-слаб, петте състояния на value_flag, peer-нормализация с праг ≥30, портативен SQLite без POWER/LN/EXP). Неутралният тон („ниската оценка е сигнал за преглед, не присъда") е спазен. Стълбовите формули в derive-contract-features.sql (795 реда) заслужават отделен, задълбочен pass — тук не съм ги одитирал ред по ред.

Какво е нужно преди merge

  1. Push-нете реалните fix-ове към feat/contract-health-index (head-ът трябва да се придвижи отвъд 4796a75).
  2. Блокер 1: колоните само в миграцията, преномерирана на 0003; регресионен тест, който прилага пълната свежа верига.
  3. Блокер 2: HAVING SUM(c.amount_eur) <> 0 в div_totals + тест с фикстура amount_eur=0.
  4. Ре-верификация след push.

Кодът е близо — това са две конкретни, вече диагностицирани поправки. Проблемът е чисто в това, че решенията не са в клона.

Присъда: Промени преди merge (2 блокера) — да не се слива, докато head-ът все още е 4796a75.

Adds 9 columns to contracts, amendments, tenders, and flow_pairs as the
schema and ETL foundation for the contract quality / health index. No scoring
logic — columns are populated by the existing pipeline (normalize-raw.sql,
promote-amendments.sql, precompute.sql, refresh-slice.sql).

New columns:
- contracts.exemption_legal_basis TEXT   -- правно основание за изключение
- contracts.outside_zop INTEGER          -- извън ЗОП
- contracts.dps_contract INTEGER         -- договор по ДСП
- amendments.reason TEXT                 -- причини за изменение
- amendments.circumstances TEXT          -- обстоятелства
- tenders.corrections_count INTEGER      -- брой поправки (corrigenda)
- tenders.estimated_value_eur REAL       -- estimated_value → EUR (BGN÷1.95583)
- flow_pairs.first_date TEXT             -- MIN(signed_at) over pair's contracts
- flow_pairs.last_date TEXT              -- MAX(signed_at) over pair's contracts

Migration 0002_contract_health.sql applies the columns to existing local D1
databases. 0000_init.sql updated inline so fresh installs need no migration.
The CREATE TABLE IF NOT EXISTS flow_pairs guard in precompute.sql kept in sync.
Add scripts/derive-health.sql building authority_health_rollup,
bidder_health_rollup, sector_concentration, and health_percentiles on the
served D1 (docs/contract-quality-spec.local.md §7.2/§8), and wire a new
--derive=health mode into scripts/import.mjs so these can be rebuilt
standalone against an existing local corpus without the full ~25-minute
re-import. This is the entity-grain foundation the per-contract scoring
(next PRD group) joins against.
Phase 5a+5b of the Contract Quality / Health Index (docs/contract-quality-spec.local.md
§5-§7.3, §12): scripts/derive-contract-features.sql builds contract_features (one row
per contract, 194,484 rows), populating raw leaf values, the effective peer key
(§5.6 fine→mid→coarse→GLOBAL fallback), and the [0,1] coverage score. Scoring UPDATEs
(score_a..score_overall) are left NULL for the next PRD (group 338).

Verified against the local corpus: contract_features_rows = contracts_rows = 194,484;
score_coverage non-NULL and in [0,1] for all rows; effective_peer_key non-NULL with
peer_n >= 30 or 'GLOBAL'; scoring_regime='framework' on 3,238 rows (ДСП/КС/DPS regime,
contract-grain); single_offer=1 on 78,739 rows (matches the corpus bids_received=1 tally).
…gning values

Code review on the contract_features PRD caught two gaps in the NEW-C6 first-amendment-
shock leaf: it fell back to 0 (not NULL) when signing_value was NULL/<=0 instead of
staying unknown, and compared amendments.value_delta (amendments.currency) directly
against contracts.signing_value (contracts.currency) with no currency check. Both now
resolve to NULL — re-verified against the local corpus (194,484/194,484 rows,
first_amend_shock: 179,100 NULL / 15,151 zero / 233 flagged).
… wiring

Pillar scores A-E and score_overall = 0.6*wmean + 0.4*worst on the [0,1] scale with the five-state value_flag gate; six *_quality_totals rollup grains; health derive wired into full/slice derive and ship-domain; scripts/validate-health.mjs runs the spec §10 checks (18/18 pass on the 194k-contract local corpus, 194,481 scored, 3 value_suspect unknown).
Re-run safety: drop scoring temp tables up front so a lock-retried batch re-executes cleanly; reject --catchup --derive=health instead of silently downgrading and remove the unreachable post-load health branch; guard /quality against missing health tables (pre-first-derive or mid-rebuild prod window); NULL-currency guard in first_amend_shock; >=0 floor on the B5 bid-window penalty; un-pin validate-health from the current corpus (value_suspect count, year range); surface spawn errors in the wrangler lock-retry wrapper; document the C2 linear-band choice and the §5.6 fallback-cohort limitation; honor the ranking top param; refresh stale scoring comments.
A fresh `wrangler d1 migrations apply` hit "duplicate column": the nine
health-index columns lived both in 0000_init.sql and as ADD COLUMNs in the
health migration. SQLite has no ADD COLUMN IF NOT EXISTS, so the columns now
live ONLY in the migration, removed from 0000_init; the health rollup tables
stay in 0000_init (and are rebuilt idempotently by the ETL derives for
already-migrated DBs). The migration is renumbered 0002 -> 0003 to leave 0002
to 0002_contracts_overrun_index (PRs midt-bg#170/midt-bg#171).

0000_init's second role (direct schema load for the work-DB backfill and the
sqlite-backed tests) is preserved by applying the FULL migration chain there
too: scripts/import.mjs and the db test fixtures now read every migration in
apply order, exactly like a fresh D1. migrations.test.ts applies the full
fresh chain and asserts the columns and rollup tables exist — the regression
test for this blocker.
A CPV division whose priced contracts sum to 0 EUR (a single amount_eur=0
contract, or exact +/- offsets) made win_share 0/0 = NULL and aborted the
whole derive-health.sql on sector_concentration.win_share NOT NULL — one such
row stopped the daily health refresh. div_totals now carries
HAVING SUM(c.amount_eur) <> 0, so a zero-sum division is skipped at the
source; downstream (derive-contract-features.sql LEFT JOIN) its contracts get
sector_win_share NULL — an honest unknown, never a fabricated 0 score.
New derive-health.test.ts proves the derive completes on a zero-sum fixture,
the division lands no row, and a healthy division still rolls up.
Local D1 runs the file as one batch: the bare diagnostic SELECT on tmp_b1
left a cursor that made the later DROP TABLE fail with SQLITE_LOCKED, and the
flat 6-term UNION ALL exceeded the local SQLITE_MAX_COMPOUND_SELECT. Stash
the diagnostic into tmp_diag (surfaced by the final summary SELECT) and split
the min/max compounds into nested 3+3 chains.
Crumb has no 'href' — typecheck failed; also aligns the empty-state trail
with the loaded page ('Начало').
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Rebase-нат върху main с css split — всички q-/ov- стилове са в styles/pages.css, app.css остава само @imports. Двата блокера остават затворени: свежа миграционна верига (health колоните само в 0003 + fresh-chain тест) и HAVING SUM≠0 за win_share (zero-sum тест). Prettier-чист, typecheck 7/7, 214 db теста зелени. @lyubomir-bozhinov — готов за re-check; @todorkolev — merge след стека #169#172.

…opovers

Разпределение на оценките: the chart grows from a 116px to a 182px
plot; every bin and zone label is a plain GET link (?band=0–19 |
weak|mid|good) filtering the „Договори · оценки" list to that exact
score range, with a selected state, ✕ chips at the chart and the list,
and an sr-only status announcing the range. Native SVG <title> tooltips
on bins/zones/mean marker and a ⓘ on the section heading explain how
the histogram is built (scored contracts only — unscored are never
zero); the confidence legend gets one-sentence hover explanations too.
band ships in CACHE_QUERY_PARAMS in the same commit (CWE-349) with
behavioral asserts; the DB filter is bound-param, validated at the
query boundary, and covered by exact-count narrowing tests. The ⓘ uses
the shared MetricInfo popover, hardened so text can never overflow:
white-space reset + overflow-wrap: anywhere, 320px card clamped to the
viewport, JS shift-into-viewport, coarse-pointer 44px hit area.
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Histogram click-filter (?band, кеш-ключ + тестове) + подобрен ⓘ (MetricInfo без преливане, обяснения на хистограмата/зоните/увереността) — нов head 2fff429.

…e filter

The /quality „Разбивка" ranking gains richer faceting, all server-side over
the *_quality_totals rollups:

- ?rdir=asc|desc flips the ranking for both sort keys (индекс and договори);
  the default stays the historical order (score asc — най-слабите отгоре,
  contracts desc). The section hint now reads the current direction.
- ?rfrom/?rto (ints 0–100, От/До number inputs in a no-JS GET form) filter the
  rollup rows to avg index within [from, to]; bounds are inclusive, divided to
  the stored [0,1] scale at the SQL boundary, swapped when inverted, dropped
  when malformed. Composes with grain, sort and direction; the active range
  shows a clear ✕ chip and an sr-only status line announces the row count.
- Param hygiene: rdir/rfrom/rto validated in the shared qualityRankingControls
  parser and re-checked at the query boundary; added to CACHE_QUERY_PARAMS
  with behavioral cache-key tests (CWE-349).
- The /quality grain/sort/direction/filter controls keep the viewport anchored
  (preventScrollReset) instead of jumping to the page top.
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Разбивката вече има посока на подреждане (?rdir — „най-слабите/най-добрите отгоре“, за индекс и договори) и диапазон по среден индекс (?rfrom/?rto, 0–100, GET форма без JS, валидирани + в CACHE_QUERY_PARAMS).
Нов head: e2da348.

@StanislavBG
StanislavBG force-pushed the feat/contract-health-index branch from 4796a75 to e2da348 Compare July 3, 2026 18:55
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Прав си — фиксовете бяха качени на грешния fork (StanislavBG/sigma вместо StanislavBG/sigma-pr, откъдето е отворен PR-ът). Вече е поправено: head-ът е e2da348 и носи всичко обявено:

  • двата блокера: миграцията е само 0003_contract_health (0000 е байт-идентичен с main; тест прилага пълната свежа верига) + HAVING SUM(amount_eur) <> 0 за нулевата дивизия (тест с фикстура);
  • rebase върху main с css split (q-/ov- → styles/pages.css), prettier-чист, линеен;
  • допълнително: histogram click-filter (band), посока на подреждане + диапазон по индекс за разбивката, подсилен MetricInfo.
    Извинения за объркването и благодаря за проверката — готов за re-check.

Добавя раздел „Индексът за здраве на договора" в /methodology: петте
измерения и теглата (30/15/25/20/10), формулата с думи (0,6 × претеглена
средна + 0,4 × най-слабото), пренормирането при липсващо измерение,
праговете на покритие, стойностните флагове и претеглянето по грейн
(таван 15% за институция/доставчик, непретеглено по година) — сверено
срещу quality.ts и derive-contract-features.sql.

Само документация; без промяна в поведението.
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Ре-верифицирах на текущия head (7437a57) — двата блокера са затворени:

  • Миграция: свежо wrangler d1 migrations apply минава чисто (0000→0001→0003); дублирането на деветте колони между 0000_init и health миграцията вече го няма. Преномерирането на 0003_contract_health маха и колизията с 0002_contracts_overrun_index на feat(web): overruns dashboard #171.
  • derive-health.sql: CPV дивизиите със сума 0 се филтрират преди INSERT-а, тъй че win_share NOT NULL вече не се нарушава.

От моя страна блокерите падат — благодаря за бързия fix (и за изясняването с грешния fork). Останалото (CI/maintainer approve) е извън Triage.

@StanislavBG

Copy link
Copy Markdown
Contributor Author

@todorkolev готов за ревю 🙏 — rebase-нат на main, CI зелен, prettier-чист, CSS промените в styles/* (app.css само @import). Резолвнати нишки. Approve-ни когато ти е удобно.

- import.mjs: gate checkContractFeaturesIntegrity in runFullDerive/runSliceDerive
  too, matching ship-domain.mjs, so a local derive enforces the same
  contract_features invariants as the daily prod ETL.
- precompute.sql: stop guessing an FX rate from today's date for tenders with
  a NULL published_at — leave estimated_value_eur NULL explicitly instead.
- validate-health.mjs: exclude the synthetic NA bucket from the >60%-NULL
  threshold check (it's informational-only, out of the check's stated
  2020-2026 scope); guard the decile-correlation check against zero-variance
  pillars instead of printing NaN.
- trendAxis.ts: drop the stale TODO(midt-bg#170), already tracked by the issue itself.

Reply drafts for the 4 confirmation-only threads (no code change needed):

- filters.ts qualityRankingControls: confirmed — quality.ts's rankSql call
  binds every rank param exclusively via `.prepare(...).bind(minScored,
  ...rangeParams, top)` (packages/db/src/queries/quality.ts:235-236), never
  string-interpolated; rankFrom/rankTo/rankDir are re-validated in
  qualityRankingControls (regex + numeric clamp) before reaching the query.
- analytics.tsx getQualitySummary(...).catch(...): thanks, confirmed.
- 0003_contract_health.sql numbering: confirmed both — (1) 0002_contracts_overrun_index
  (PR midt-bg#170/midt-bg#171, not yet merged) adds no column/table these ALTERs read, so
  applying 0003 first is safe; (2) every environment applies migrations only
  via `wrangler d1 migrations apply` (tracked, once-only), never raw
  re-execution.
- ship-domain.mjs DROP+CREATE window: acknowledged as a real operational risk,
  tracked as a separate follow-up per PRD 530 — not re-litigated here.
…index

# Conflicts:
#	apps/web/app/lib/filters.test.ts
#	apps/web/app/lib/filters.ts
#	apps/web/app/styles/components.css
#	apps/web/workers/cache-key.ts
…AL_QUERY_PARAMS

The origin/main merge brought in a stale CANONICAL_QUERY_PARAMS (predating the
g->step trends rename and missing the /quality params this PR adds). Restore
the full param set so withParams/cache-key don't silently drop them from
generated links and cache keys.

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ревю на PR: feat: индекс на качеството на договорите (ETL оценка 0..1 + страница)

Обобщение

ВЕРДИКТ: COMMENT — прегледът обхваща 8 партиди; няма блокиращи проблеми със сигурността или коректността. Има няколко въпроса за потвърждение и незадължителни бележки за полиране.

Какво прави PR-ът

PR въвежда изчислен от ETL индекс на качеството на договорите (нормализирана оценка в диапазон 0..1) и нова публична страница за визуализацията му. Промяната обхваща цялата верига:

  • ETL слой — нови SQL скриптове (derive-contract-features.sql, derive-health.sql) с формула за оценка по стълбове, пренормализация на теглата и твърди integrity gates, които проваля ETL при нарушен инвариант.
  • Схема и типове — адитивни, обратно-съвместими миграции (0003_contract_health.sql, нови nullable колони) и разширения в api-contract (TrendGranularity, документиран инвариант „NULL = недостатъчно данни, никога 0").
  • Слой за заявки — нов read-only queries/quality.ts и разширения на queries/trend.ts (тримесечна гранулация, разпределения по CPV групи).
  • UI — нова страница /quality, разширения на /trends, споделена ос за графики, SVG визуализации без chart библиотека, достъпни компоненти (MetricInfo), CSS и документация (methodology.tsx, docs/etl.md).
  • Операторски инструментscripts/validate-health.mjs за валидация на локалния D1/SQLite файл.

Силни страни

  • Сигурност — чиста през всички партиди. Няма зашити тайни, нови външни URL адреси, нови зависимости, обфускация или backdoor шаблони. SQL достъпът е параметризиран (.bind() / ? placeholders); единствените интерполирани в SQL стойности са вътрешни литерали след строга валидация (allow-list за dir, grain; regex за band, cpv, rankFrom/rankTo). Целият потребителски вход в UI се екранира от JSX (без XSS). Валидацията на ranking контролите (CWE-349) е образцова и целенасочено тествана срещу SQL-подобни входове.
  • Защита срещу cache poisoning — новите query параметри са добавени синхронно и в CANONICAL_QUERY_PARAMS, и в PARAM_ORDER, с тестове, доказващи различни cache ключове.
  • Целост на данните — неутралната позиция от спецификацията е спазена последователно навсякъде: липсваща оценка → , никога фалшива нула; value_suspect и покритие < 0,40 се изключват от средните.
  • Изключително силно тестово покритие — integrity gates, guard срещу дрейф на схемата (тройно сравнение на DDL), гранични случаи; тестовете са смислени, не тривиални.

Въпроси за потвърждение (не блокиращи)

  1. Съвместимост на URL: параметърът g е премахнат от CANONICAL_QUERY_PARAMS в полза на step. Стари линкове /trends?g=year вече изпускат параметъра тихо — потвърдете дали е добавен редирект или толерантно четене, или дребният пробив в съвместимостта е приемлив.
  2. Тиха нормализация на филтри: невалидни year/cpv/непознати angle/step се свеждат мълчаливо към стойности по подразбиране; предишният callout „Непознат филтър" е премахнат. Потвърдете, че липсата на обратна връзка при сгрешен филтър е умишлена.
  3. CDN cache-ключ: страницата /quality е публично кеширана (max-age=1800), а съдържанието зависи от query параметри (band, sel, contract) — уверете се, че cache-ключът включва query string.

Наблюдения за follow-up (не блокиращи)

  • Крехкост при номерирането на миграциите — липсва 0002; смекчено с документация и migrations.test.ts, който заковава точния набор файлове. EXPECTED_MIGRATION_FILES изисква ръчна синхронизация при бъдещи миграции.
  • Неатомарен ребилд на прод D1DROP TABLE IF EXISTS contract_features + нов build създава прозорец на неналичност (документирано като follow-up в кода).
  • INNER JOIN bidders/tenders може да изпусне договор с NULL/orphan bidder_id, което после проваля целия ETL през паритетния gate — умишлен fail-loud дизайн; потвърдете, че contracts.bidder_id никога не е NULL в корпуса.
  • Гранична неточност „(показани първите 24)" в trends.tsx — текстът се показва и при точно 24 договора; заявка с limit: 25 и показване само при > 24 би било по-коректно.
  • qualityOverview — при хипотетичен оценен ред с score_coverage IS NULL confidence кофите няма да сумират до scored; COALESCE(score_coverage, 0) или CHECK инвариант би направил стойността експлицитна.
  • Тип на year в validate-health.mjs (проверка №3) — възможно несъответствие низ спрямо стойност от базата; за изясняване.
  • DRY нит — повтарящ се grid шаблон в pages.css (.ov-cpv-head/.ov-cpv-row и .ov-cpv-foot) би могъл да се обедини.
  • Незадължителен тест, който да докаже, че една и съща логическа заявка дава същия cache ключ (стабилност на подредбата), за да се хване регресия към прекалено фрагментиран кеш.

Заключение

Качествена, добре структурирана, обстойно документирана и изключително добре тествана промяна, следваща правилата от CLAUDE.md (без частична имплементация, TODO-та, дублиран или мъртъв код). Няма открити уязвимости по сигурността или проблеми с целостта на данните в нито една партида. Блокиращи концерни няма; преди merge препоръчвам да се адресират трите въпроса за потвърждение по-горе.

Comment thread apps/web/app/lib/query-params.ts
Comment thread apps/web/app/routes/quality.tsx
Comment thread apps/web/app/routes/quality.tsx
Comment thread packages/db/migrations/0003_contract_health.sql
Comment thread scripts/derive-contract-features.sql Outdated
Comment thread scripts/derive-contract-features.sql Outdated
Comment thread scripts/ship-domain.mjs
Comment thread scripts/precompute.sql
Comment thread scripts/validate-health.mjs Outdated

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах в дълбочина на текущия HEAD (121c5f22) — data-слоя, миграционния модел, скоринга и рамката. Силна, спец-водена работа. Какво проверих, че държи:

Миграционен модел — коректен (обратното на капана в #251). Нови таблици0000_init.sql + идемпотентен CREATE TABLE IF NOT EXISTS в derive-health/derive-contract-features (за вече-мигрираното served D1); нови колони на съществуващи таблици → само в 0003 през ALTER (защото SQLite няма ADD COLUMN IF NOT EXISTS, а fresh D1 пуска цялата верига). runWorkBackfill вече прилага цялата миграционна верига към work-DB-то (import.mjs:327-335), тъй че 0003-колоните съществуват и там. Взехте 0003, за да не влизате в четворната 0002 колизия. Подредбата 0002↔0003 е обмислена и документирана. Чисто.

Скоринг — методологически издържан и репутационно внимателен. score_overall = 0.6·wmean + 0.4·worst, ренормализиран само по наличните стълбове (липсващ стълб се изключва от средното, НЕ се брои като 0 — worst е MIN с COALESCE(...,1.0)); всеки под-скор е клампнат MAX(0,MIN(1,…)), деленето е пазено (wsum=0 → NULL); value_suspect е изключен/НЕОЦЕНЕН. Адверсариалните self-checks (value_suspect_leak_rows, a1_floor_violations…) са вързани в твърд ship-gate (checkContractFeaturesIntegrity + assertIntegrity, ship-domain.mjs:242-248). Health се деривира на served D1 по двата пътя (D1 full-derive + ship-domain след ship), а DROP/rebuild прозорецът е толериран от /quality.

Рамката на /quality е образцова: „Сигнал за преглед, не присъда", изричните ограничения („не открива картели… конфликт на интереси"), при NULL (никога фабрикувана 0), уговорки по покритие. Точно както трябва за индекс, който слага число до име на публична страница.

Две бележки (не блокират):

  1. Зависимост от #257 (евро-анекси). Стълб C (стойност) и derive-health.avg_cost_overrun/перцентилите четат current_value_eur/signing_value_eur. Договорите в лева с евро-анекс от 2026 са с наполовинен current_value_eur (и НЕ се хващат от annex_suspect, ratio≈0,51<100), тъй че реалното превишение изчезва → тези договори излизат фалшиво по-здрави по C, докато #257 не поправи current_value_eur. Само за merge-координация (C ще е коректен след #257).

  2. Физически лица на /quality. Страницата показва именувани изпълнители със скор (quality.tsx:1006/1044 → entityName, който не маскира ЕТ) и няма noindex/ЕТ-обработка. ЕТ би излязъл по име под съставна „здравна" оценка на индексируема страница. Не е регресия на този PR (flows/competition правят същото), но #237 току-що въведе noindex за ЕТ на страницата на договора — редно е същият модел да се приложи и тук (и на /overruns), като политика за целия сайт.

Иначе — одобрявам. Наистина добра работа.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Уточнение към бележка (1) по-горе — механизмът, който посочих, беше неточен. annex_suspect не хваща тези договори не заради „ratio≈0,51<100": този праг сравнява суровото native current_value/signing_value >= 100 (refresh-slice.sql:678), което за реален анекс е ~1,7 — никога близо до 100. По-точната причина: целият value_flag път взима решение върху правилно конвертирания eff_eur (current_value по current_value_currency), затова редът законно е 'ok'. Изкривяването ÷1,95583 се инжектира изцяло надолу по веригата в new_amount_eur — нищо в логиката на флаговете не е сгрешено, само финалната сума. Изводът за стълб C е непроменен: докато #257 не поправи и amount_eur, стойностният пилон чете наполовинена стойност. (0,51 е коефициентът на изкривяване, не отношение — сгреших ги в оригиналната бележка.)

…op orphan rows safely

Build the new scores into a disposable contract_features_next staging table and
swap it into the live contract_features name with a back-to-back DROP+RENAME in
the same wrangler batch, so served D1 never has contract_features missing/empty
mid-rebuild. Also LEFT JOIN tenders/bidders instead of INNER JOIN so a dangling
contracts.tender_id/bidder_id scores the row as unknown instead of silently
dropping it from the feature store and failing the contract_features_rows ==
contracts_rows integrity gate.
… fx lookups

precompute.sql's tender estimated_value_eur conversion silently leaves foreign-
currency estimates NULL when no fx_rate row falls inside the 10-day lookback.
Track that count in a new pipeline_diag table and print it in the run summary
(fx_rate_gap_rows) so a systemic fx_rates coverage gap is visible instead of
invisible.
…NTEGER year

Normalize both sides of the year_quality_totals coverage comparison through
String() before comparing, so the check passes regardless of whether the
driver hands back `year` as text or a number — previously a type mismatch
would silently always-FAIL. Extracts the comparison into an exported
missingYears() and adds a unit test covering the INTEGER-column case.
precompute.sql's CREATE TABLE IF NOT EXISTS pipeline_diag was bootstrap-only,
missing from packages/db/migrations/0000_init.sql unlike every sibling rollup
table (home_totals, *_quality_totals, etc.) per the file's own stated
"canonical definitions live in migrations" convention.
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Обобщение след 07-23 вълната (main мръдна съществено):

Логиката на индекса си остава силна (прегледът ми от 07-21). Само rebase-ът е нетривиален заради застъпването във въпросните SQL файлове.

…, suppress RSC-only CSRF

Bump postcss to ^8.5.18 (GHSA-r28c-9q8g-f849) and valibot to ^1.4.2
(GHSA-5qjj-4xww-7phc) via pnpm overrides - both patch-level, non-breaking
fixes. Bump react-router/@react-router/dev to ^7.18.0 via override, fixing
4 real advisories (SSR hydration constructor injection, unauthenticated DoS,
RSCErrorHandler XSS, open-redirect via backslash) - all fixed within the 7.x
line, no major bump needed. Add a time-boxed osv-scanner.toml suppression for
the one remaining advisory, GHSA-qwww-vcr4-c8h2, a CSRF flaw scoped to
unstable RSC APIs this app does not use (verified via repo-wide grep) with
no fix in the 7.x line; bumping to 8.x is out of scope for this patch.
…flicts

Resolves conflicts across 12 files created by main's independent evolution
(read-only D1 chokepoint getDb(), CVE suppressions, migration/backfill
scripts) diverging from this branch's contract health index work:

- osv-scanner.toml: keep both independent CVE suppressions
- apps/web/app/routes/{analytics,trends,quality}.tsx: keep this branch's
  quality/health-index features and trends explorer rewrite, adopt main's
  getDb(context.cloudflare.env) chokepoint in place of raw env.DB
- packages/db/src/*.test.ts, scripts/import.mjs, scripts/normalize-raw.sql:
  keep this branch's full migration-chain loader (0000-0003) and the
  Contract Quality / Health Index derive/gate wiring, combine with main's
  async integrity-check runner and amendment-currency CTE
- scripts/integrity-checks.mjs: merge main's async runner/checks (D1
  support) with this branch's checks-param narrowing; fix
  checkContractFeaturesIntegrity, which relied on a since-async'd
  tableExists()/rows() without awaiting them (always-false self-skip)
- packages/db/src/{contractor-identity,etl-entity-canonicalization}-sql.test.ts,
  migrations.test.ts: extend fixture migration chains to include 0003 now
  that normalize-raw.sql/precompute.sql depend on its health-index columns
- apps/web/app/routes/trends.test.ts: mock the new getDb export
- pnpm-lock.yaml: regenerated via pnpm install

Full typecheck + test matrix (all 7 packages) green after resolution.
…dvisories doc

Lint and Docs integrity checks were red on CI. prettier --check flagged
unformatted code in scripts/integrity-checks.mjs; check-docs flagged
docs/security-advisories.md as an orphan doc not linked from docs/README.md.
todorkolev added a commit that referenced this pull request Jul 29, 2026
… + hardening) (#212)

* perf(db): ordering indexes for the non-default list sorts

The list pages keyset-paginate with ORDER BY <sortExpr> <dir>, <id> <dir> LIMIT N.
Six user-selectable sorts had no matching index, so the planner fell back to a
full table SCAN + temp-B-tree ORDER BY on every page (D1 bills rows scanned):

  /contracts   date-desc, date-asc   (idx_contracts_signed is on the bare column,
                                       not the COALESCE(signed_at, ...) expr the query uses)
  /companies   count, authorities
  /authorities count, avg

Add one index per missing sort, matching the exact ORDER BY expression plus the
keyset id tiebreak, so SQLite walks the index and stops at LIMIT. Additive,
idempotent; rollup tables are DELETE+INSERT-refreshed so the indexes survive ships.
A sqlite3 EXPLAIN QUERY PLAN test proves each sort full-scans before and index-walks
after.

* fix(web): escape < in the JSON-LD data island (defense-in-depth)

root.tsx embeds JSON-LD via dangerouslySetInnerHTML with a raw JSON.stringify.
JSON.stringify does not escape '<', so a '</script>' in any string value would
close the <script> element early (stored XSS) — the exact sink the project's own
review standard (docs/review-security.md) requires be escaped. Today only the
request origin reaches the graph (new URL() cannot make it carry '</script>'), so
this is not currently exploitable; the jsonLdScript helper closes the sink
pre-emptively for any DB/user-derived field added later. A unit test proves '<' is
escaped, U+2028/U+2029 are escaped, and the output stays JSON-equivalent.

* chore(db): renumber list-sort-indexes migration 0002 → 0005

De-conflict the migration number: 0002 is claimed by the contracts_overrun_index
family (#169/#170/#171/#172), 0003 by #188 (contract_health), and 0004 by #210
(cpv_division_stats). 0005 is the next free number. Additive/idempotent, so final
merge order stays the maintainer's call; this just removes the known 0002 clash.

* test(db): apply all migrations + cover keyset pages; guard jsonLdScript(undefined)

Address the review notes on the list-sort-indexes PR:

1. The sort-index test now applies EVERY migration on the branch (discovered from
   the migrations dir), not a hardcoded 0000/0001/000N subset. The "BEFORE" base is
   exactly the real served schema minus this PR's index, and the test survives any
   renumbering. (Confirmed: company_totals/authority_totals are created in 0000 and
   nothing between affects these sort plans.)

2. Each sort now asserts the plan on the keyset page too - the real paginated path
   `WHERE (expr <cmp> ? OR (expr = ? AND id <cmp> ?))`, not only the first page.
   Full-scans BEFORE and index-walks (no temp B-tree) AFTER, on both pages.

3. jsonLdScript now returns "null" when JSON.stringify yields undefined (undefined /
   function / symbol) instead of throwing on the following .replace - defense-in-depth
   for the documented "safe for any future field" helper. Covered by a test.

* refactor(web): rename jsonLdScript → serializeJsonForScript + document sort-index sync

Address the (non-blocking) review nits:

- Rename jsonLdScript to serializeJsonForScript: the helper returns a serialized
  JSON string safe to embed in an inline <script>, not a <script> element (review
  ydimitrof). Updates root.tsx and the test.

- Document the sentinel sync: the COALESCE defaults in queries/contracts.ts SORTS
  ('' / '9999-99') must stay byte-identical to the expression indexes, or SQLite
  silently drops the index and falls back to a full scan + temp-B-tree sort. Added
  reciprocal SYNC comments in the migration and the SORTS map, both noting that
  list-sort-indexes.test.ts's EXPLAIN assertions catch a drift.

* docs(db): state the boundaries of the sort-index guarantee (review)

Document the two known limits of the EXPLAIN-plan proof, per review: (1) the local
sqlite3 CLI planner is not version-identical to Cloudflare D1's (a strong
indication, not a bit-exact production proof; the binary itself is a pre-existing
suite-wide dependency), and (2) the index-walk guarantee covers the UNFILTERED
sort paths - with an active filter the planner may prefer the filter's index and
temp-sort the much smaller filtered set, which is the correct trade. Comment-only.

* chore: drop internal review-marker traces from code comments

Remove the '(review ydimitrof)' attribution artifacts from json-ld.ts and
list-sort-indexes.test.ts comments; the explanations stay. Comment-only.

* refactor(web): share one JSON-for-script serializer between the JSON-LD island and .json route

The .json contract endpoint had its own safeJson escaper, a second implementation
of the same <script>/separator escaping as serializeJsonForScript - a DRY smell the
comment itself admitted, and a drift risk (one could add a U+2028 escape the other
lacks). Route it through the shared serializer instead. It escapes every `<` (vs the
old `</`-only form) - JSON-equivalent, harmless for the JSON body, strictly safer.

Also document, in the shared helper, why `>` and `&` are deliberately left unescaped
(only `<` can start a token in a script raw-text context), with a test that locks it.

* fix(web): set nosniff on the .json route; add planner-independent sentinel-sync test

- contract.json.tsx: the actual MIME-sniffing defense is X-Content-Type-Options:
  nosniff, not the content escaping. The worker already sets it globally
  (baseSecurityHeaders); set it explicitly on this resource route too so it is safe
  on its own, and correct the comment that over-credited the escaping (review).

- Add sort-index-sentinel-sync.test.ts: the date-sort index only matches while its
  COALESCE sentinel is byte-identical to SORTS in queries/contracts.ts. A .sql
  migration can't import a TS constant, so guard the coupling with a static
  cross-file check of the sentinels ('' and '9999-99') that fails on drift
  regardless of the DB engine - independent of the local sqlite3 planner the EXPLAIN
  test relies on (review).

* chore: drop stray review-marker artifacts from this PR's comments

Remove the bare '(review ...)' attribution notes I left in contract.json.tsx and
sort-index-sentinel-sync.test.ts; the explanations stay. The pre-existing
'(review #80)' issue references elsewhere are an established convention and are
untouched. Comment-only.

* test(db): cover filtered list sorts and guard the sqlite3 dependency

Two review follow-ups on the ordering-index test:

- Filtered sorts were documented as out of scope, leaving the reader unable to
  tell whether an active list filter makes the ordering index redundant. It does
  not: with a sector (tenders.cpv_code) or eu-funded filter the planner still
  walks idx_contracts_signed_desc and drops the sort step, while the pre-index
  baseline sorts the whole table. Asserted both directions.
- A missing sqlite3 CLI surfaced as an opaque ENOENT. Probe it in beforeAll and
  fail with the fix. Deliberately not a skip: this is a perf/cost gate, and
  silently passing it on an image without sqlite3 would retire the gate.

---------

Co-authored-by: Rumen Slavov <26761822+B353N@users.noreply.github.com>
Co-authored-by: todorkolev <tkolev@obecto.com>
todorkolev added a commit that referenced this pull request Jul 29, 2026
…аницата на договора (#210)

* feat(db): per-cpv-division value percentiles rollup + cohort benchmark query

* feat(web): similar-contracts value benchmark on the contract page

* fix(web): harden similar-contracts cohort labels + ETL placement (review)

Addresses the Request-Changes review on the „Подобни договори" benchmark:

Label correctness (cohortBand, was `>=` cascade over a shared, self-inclusive grid):
- #1 ties: strict `>` for the top bands + require each anchor to be strictly above
  the next coarser one, so a tie-collapsed cohort (p99=p95=…=median) or a value
  merely equal to an anchor never reads as „top 1%". Symmetric guard on „bottom 25%".
- #2 tiny cohorts: per-band minimum cohort sizes (top1≥100, top5≥40, top10≥20,
  top25≥12), so the single most-expensive contract in a 12-row cohort is not „top 1%".
  The self-inclusion caveat is now stated in the UI + methodology, not only in code.
- #3 median: a value at the nearest-rank median maps to a new `at-median` band
  instead of „above median"; strict comparisons on both sides.
- #5: UI + methodology note that this uses a different method than the anomaly
  report, so the two figures may differ.

Redundant read (review comment 3): the cohort is now computed inside getContract
from the row it already read (stats load in the existing Promise.all), returned as
`ContractDetail.cohort` — no second contract scan, no added latency.

ETL (#4): the ~150k-row window recompute moved out of the shared `globals` batch
into its own `@refresh-batch cohort-stats` step, so its CPU cost can't fail the
whole globals step.

Coordination: renumbered this migration 0002 → 0003 to de-conflict with the
0002 in the list-sort-indexes branch.

Regression tests cover each labelling case (ties, tiny cohort, at-median,
non-distinct anchors) and the pure contractCohort gate.

* fix(web): drop the false „2020 г. - днес" scope claim from the cohort label

The cpv_division_stats cohort has no signed_at bound — it spans every priced
clean-value contract in the division (matching sector_totals and the /contracts
list, which are also not date-bounded), incl. pre-2020-signed and undated rows.
The UI hint and methodology claimed „(2020 г. - днес)" / „от 2020 г. насам",
promising a window the query never enforces (review lyubomir-bozhinov).

Fix the copy rather than add a date bound: bounding only the cohort would break
the cohort↔rollup membership invariant (a shown contract must be a real member of
the cohort it is compared against) and diverge from the site-wide value basis.
The methodology now states the comparison is over the full corpus, same scope as
the sector totals.

* chore(db): renumber cpv-division-stats migration 0003 → 0004

De-conflict with the 0003 already claimed by #188 (contract_health). 0002 is
taken by the list-sort-indexes branch, so the next free number is 0004. Final
merge order stays the maintainer's call; this just removes the known 0003 clash.

* perf(db): skip the cohort rollup read for non-clean-value contracts

getContract gated the cpv_division_stats PK read only on CPV presence, but
contractCohort returns null anyway when value_flag <> 'ok' or amount_eur is
null/<=0 - so a suspect/valueless contract paid for a read it always discarded,
against the PR's own "one extra rollup read, not a second scan" goal (review
ydimitrof). Apply the clean-value gate to the read itself; behaviour is identical.

Also close the integration gap in details.test.ts: a case with a real
cpv_division_stats row now asserts detail.cohort is populated end-to-end (catches
an argument-order swap into contractCohort), plus a case proving the read is
skipped entirely for a non-clean value.

* style(web): use the Bulgarian closing quote (U+201C) in the cohort sector label

The „Подобни договори" section opened with „ (U+201E) but closed with a straight
" (U+0022). Match the Bulgarian pair „…“ (review ydimitrof). Cosmetic only.

* fix(web,db): repair duplicated imports and pin-free migration list

Two breakages surfaced once CI actually ran on this branch (the fork workflow
runs were awaiting approval, so neither had been reported):

- contract.tsx carried both sides of an earlier main merge, so
  contractIdFromSlug/getContract/ContractDetail were each imported twice and
  typecheck failed with TS2300. Keep the main-side @sigma/db import (it has
  contractSlug and getDb) and the branch-side type import (it adds CohortBand).
- precompute-cohort.test.ts pinned the migration list to 0000/0001/0004,
  skipping 0002_current_value_currency. precompute.sql reads that column, so
  the test DB no longer matched the served schema and sqlite3 aborted with
  'no such column: current_value_currency'. Read every migration from the
  directory instead, sorted - the same approach the sort-index test uses.

---------

Co-authored-by: Rumen Slavov <26761822+B353N@users.noreply.github.com>
Co-authored-by: Todor Kolev <tkolev@obecto.com>
todorkolev pushed a commit that referenced this pull request Aug 8, 2026
safeD1 tested err.message for "no such table", but wrangler prints the
SQLite error as JSON on stdout and leaves the exception message as a bare
"Command failed: wrangler d1 execute ...". The guard therefore never
matched, safeD1 rethrew instead of returning [], and the data_freshness
fallback in latestLoadedDate was unreachable.

The visible effect is that `pnpm run import --catchup --plan-only` crashes
whenever the transient staging tables are absent, which is their normal
steady state: drop-transient-staging removes them in a finally, and
--plan-only exits before the main flow recreates them. Ordinary runs are
unaffected, since they create an empty raw_contracts first and so reach the
fallback.

Cherry-picked verbatim from e3fc6b3 in #188 by Bilko (StanislavBG), split
out so the fix can land without the contract health index feature.
todorkolev added a commit that referenced this pull request Aug 8, 2026
safeD1 tested err.message for "no such table", but wrangler prints the
SQLite error as JSON on stdout and leaves the exception message as a bare
"Command failed: wrangler d1 execute ...". The guard therefore never
matched, safeD1 rethrew instead of returning [], and the data_freshness
fallback in latestLoadedDate was unreachable.

The visible effect is that `pnpm run import --catchup --plan-only` crashes
whenever the transient staging tables are absent, which is their normal
steady state: drop-transient-staging removes them in a finally, and
--plan-only exits before the main flow recreates them. Ordinary runs are
unaffected, since they create an empty raw_contracts first and so reach the
fallback.

Cherry-picked verbatim from e3fc6b3 in #188 by Bilko (StanislavBG), split
out so the fix can land without the contract health index feature.

Co-authored-by: Bilko <StanislavBG@gmail.com>
@nedda76

nedda76 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Този клон е в конфликт с main, тъй че към момента не може да се ревюира — дифът, който GitHub показва, вече не отговаря на това, което би влязло. Ще го пребазираш ли върху актуалния main (или merge на main в клона) и да разрешиш конфликтите? След това веднага го поглеждам. Благодаря! 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants