Skip to content

feat: свързани лица — детерминистична основа за данни за конфликт на интереси - #226

Merged
todorkolev merged 51 commits into
midt-bg:mainfrom
lyubomir-bozhinov:feat/related-persons-foundation-upstream
Aug 4, 2026
Merged

feat: свързани лица — детерминистична основа за данни за конфликт на интереси#226
todorkolev merged 51 commits into
midt-bg:mainfrom
lyubomir-bozhinov:feat/related-persons-foundation-upstream

Conversation

@lyubomir-bozhinov

@lyubomir-bozhinov lyubomir-bozhinov commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Чернова. Отваря се за ранен преглед на основата за данни, преди да се
включат публичните повърхности. Слива се едва след като числата от Фаза 0
(over-merge = 0, измерен auto-match rate, размер на двусмислената опашка) са
потвърдени срещу реалния корпус.

Накратко

Детерминистична основа за данни за слоя „свързани лица": свързва
изпълнители по обществени поръчки с длъжностни лица (и, анонимизирано,
техни близки), декларирали финансов интерес в тях. Само публични данни.

Свързаност „по собственост" от Търговския регистър е извън обхвата (виж #60).
Този слой я заобикаля с друг публичен източник — декларациите за имущество
и интереси по ЗПК, публикувани в Публичния регистър на Сметната палата
(чл. 75 ЗСП). Съпоставя се само деклариран дял (свой или на свързано лице),
никога изведена свързаност по ТР.

Обхватът тук е контингентен, а не „отключващ" #60/#128 — доставя данните и
инвариантите, върху които стъпват тези дискусии, но не ги затваря.

Спецификация: docs/spec/related-persons-foundation.md.

Какво съдържа

ETL (scripts/, scripts/cacbg/, packages/ingest/)

  • Скрейпър на регистъра (register.cacbg.bg) — resumable, кеширан по
    xml_file+ControlHash, host-scoped TLS (без глобален bypass).
  • Парсване на декларираните дялове (лице, институция, длъжност, година,
    фирма + правна форма, град, стойност, вид на връзката).
  • Консервативен нормализатор на имена — production-grade, 100% тестван;
    единственият слой за клевета. Нула over-merge на ръчно етикетиран набор
    (form-only разлики като „АЛФА" ЕООД срещу „АЛФА" АД остават разделени).
  • Детерминистичен matcher по две линии: (1) декларирано пълно име → точно
    съвпадение с целия bidders → сдвоеният ЕИК (уникален по ЗТРРЮЛНЦ чл.21 т.7);
    (2) когато деклараторът сам е изписал ЕИК заедно с фирмата (двойна проверка
    име+ЕИК), ЕИК-ът — националният уникален идентификатор — разрешава фирмата
    дори при родово име (ниво A_eik, ADR-0028).
  • Съпоставяне на стойността към периода на декларирания интерес
    (contemporaneous), не за целия живот на връзката — ADR-0024.

Заявки и представяне (packages/db/, apps/web/)

  • Класиране „свързани лица", профил на длъжностно лице / на фирма, търсене.
  • Подредба и стойности спрямо декларирания период (моментна снимка, не
    собственост).
  • Публикуват се само класове private_ownership / family_ownership със
    статус published; ex-officio/управление никога не се показва.

GitOps / CI-CD

  • related-persons-data.ymlworkflow_dispatch (dev/staging/prod, full_crawl
    toggle): Extract→Hydrate→Resolve→Audit→Apply schema→Ship→Reindex.
  • scripts-test.yml — node:test за standalone скриптовете + CACBG pipeline.
  • deploy.yml прилага схемата 0002_related_persons_foundation.sql към целевата
    среда преди деплой на Worker-а (идемпотентно, CREATE … IF NOT EXISTS).

Решения (ADR)

  • ADR-0024 — contemporaneous стойност (read-time, без съхранена колона).
  • ADR-0025 — верига на доставки за XML парсера.
  • ADR-0026 — идентичност на лицето: ключ (име, ведомство).
  • ADR-0027 — гейтът за over-merge при зареждане е телеметрия, не порта;
    доказателството е етикетираният тест.
  • ADR-0028 — деклариран ЕИК е определящ идентификатор (ниво A_eik),
    освободен от ТР-преброяването.

Инварианти (защита срещу неоснователно обвинение)

  • Само деклариран дял в затворени форми (ООД/ЕООД); поименни акции — не.
  • Близките остават анонимни: никога име, роднинска връзка или лични данни.
  • „Модел за проверка, а не обвинение"; конфликт се твърди само при припокриване
    с декларирания период.
  • Двусмислените по име съвпадения не се публикуват автоматично — освен
    когато деклараторът сам е изписал ЕИК заедно с фирмата; тогава основанието е
    ЕИК-ът (националният уникален идентификатор), не името (ADR-0028).

Обхват на промяната

Тази чернова е самостоятелна и напълно съвместима с main — не носи никаква
ephemeral/preview инфраструктура; всяка стъпка от CI/CD работи за staging и prod
както е.

Мащаб: 99 файла, ~9,9k реда, 22 ADR-а (0007–0028), ~200 теста.

Свързани: #60 (дискусия), #128 (картели — отделен слой).

Data foundation that links declared ownership stakes of public officials
(КПКОНПИ asset-declaration register) to companies winning public procurement
(ЦАИС ЕОП): where an official — or an anonymized close relative — holds a stake
in a contract-winning firm. Search, leaderboard, and per-person / per-company /
per-contract pages, behind a feature flag.

Libel-safe by construction: deterministic name→ЕИК match (the normalizer is the
sole libel surface, 100% covered with hard negatives), zero over-merge, close
relatives never named, only private/family ownership surfaced (ex-officio and
management roles excluded), a read-time contemporaneous split so a divested
stake is never asserted as current, and all values computed in SQL.

Includes the staging/prod CI/CD needed to ship it: the migration 0002
schema-apply step in deploy.yml, the scripts-test workflow (CACBG pipeline +
scripts unit tests), and the related-persons-data ETL workflow (crawl → extract
→ resolve → audit → ship → reindex). Per-PR preview and dev-environment
provisioning are deliberately excluded — those stay on the fork.
lyubomir-bozhinov added a commit to lyubomir-bozhinov/sigma that referenced this pull request Jul 11, 2026
…ity telemetry

The load-time trueOverMerge_LIBEL_GATE was a structural false-zero: it tiebroke a multi-ЕИК
bucket on a strictKey that strips a superset of what companyNameKey folds (whitespace + quotes
+ .,-), so any two names sharing a companyNameKey necessarily shared a strictKey — the second
clause could never be true, the counter was hardwired to 0, yet the loader printed
'0 over-merges' and could exit(1) on it (review midt-bg#226).

Replace it with honest telemetry: report keys that map to >1 distinct valid winner ЕИК
(ambiguous_name_keys + examples), tied to NO exit code. These are already quarantined by the
resolver (never published), so they carry no libel exposure — on the real corpus all 54 are
presentation-only collisions (generic names / feed typos). The sound 0-over-merge proof stays
where it belongs: the labelled company-name-key.test.ts.

Proven data-neutral: substantive columns of persons/interest_links are byte-identical
old-vs-fixed loader (only created_at flutters). Adds ADR-0027, refines the spec's Phase-0
claim, and spells out the ambiguous/generic-name quarantine on the public methodology page.
…ity telemetry

The load-time trueOverMerge_LIBEL_GATE was a structural false-zero: it tiebroke a multi-ЕИК
bucket on a strictKey that strips a superset of what companyNameKey folds (whitespace + quotes
+ .,-), so any two names sharing a companyNameKey necessarily shared a strictKey — the second
clause could never be true, the counter was hardwired to 0, yet the loader printed
'0 over-merges' and could exit(1) on it (review midt-bg#226).

Replace it with honest telemetry: report keys that map to >1 distinct valid winner ЕИК
(ambiguous_name_keys + examples), tied to NO exit code. These are already quarantined by the
resolver (never published), so they carry no libel exposure — on the real corpus all 54 are
presentation-only collisions (generic names / feed typos). The sound 0-over-merge proof stays
where it belongs: the labelled company-name-key.test.ts.

Proven data-neutral: substantive columns of persons/interest_links are byte-identical
old-vs-fixed loader (only created_at flutters). Adds ADR-0027, refines the spec's Phase-0
claim, and spells out the ambiguous/generic-name quarantine on the public methodology page.
…ll holes

Three false-negative limits the methodology page did not yet state: ownership held
through an intermediate company (HoldCo), a winner that ran under a since-changed name, and
Cyrillic/Latin script mismatch (we do not transliterate). All are safe recall misses, never
false merges — stated up front on the public methodology page.
A declared_eik match (the official wrote the ЕИК inline and the winner's name
also appears for cross-check) resolves the company via the national unique
identifier, so identity is deterministic regardless of name genericness.
Assign it the new tier A_eik and publish on that basis instead of holding it
at C_hold; it is exempt from the TR name-uniqueness census (the ЕИК already
renders name uniqueness moot). Name-only methods (exact_name_key,
extracted_name) are unchanged — their certainty is the name, so they still
ride the distinctiveness/seat gate.

Real-data effect: 3 links / 2 closely-held companies (АТЕЛИЕ ДУО ЕООД,
Файнанс Консулт ЕООД) promoted onto the public surface; the other declared_eik
links are ex-officio/management roles and stay internal.

Also surface the TR-blocked held subset on the methodology page (~400 name-only
matches, ≈€408M nominal contract value, held pending the Trade-Register census)
so the gap between declared and shown is transparent.

ADR-0028.
…tity search midt-bg#204, amendments midt-bg#165); resolve shared barrel by exporting both company-name-key and search
@lyubomir-bozhinov
lyubomir-bozhinov marked this pull request as ready for review July 11, 2026 21:44
@nedda76

nedda76 commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Ревю на PR #226 — основа за данни „свързани лица"

Прегледах черновата с фокус върху защитата срещу клевета (over-merge, публикационните инварианти) и сигурността. Заради обема и чувствителността проверих критичния път на няколко пласта; всяка находка по-долу е сверена срещу кода. Накратко: основата е стабилна — трите обявени гаранции (нула over-merge, host-scoped TLS без глобален bypass, XXE-safe парсване) издържат на adversarial проверка. Намереното е предимно defense-in-depth, плюс една дизайнерска дилема и един реален прод-блокер в CI.

Проверено като солидно

  • Нула over-merge. resolveEntity карантинира всеки ключ, който сочи към >1 валиден ЕИК ({ambiguous:true}, никога не се публикува); име-базираните методи се държат за глобална уникалност и се публикуват само ако седалището разграничи, иначе се задържат; declared_eik стъпва на ЕИК-а (националния идентификатор). audit.mjs пре-доказва инварианта на всяка публикувана връзка и спира ship-а с exit 1 при нарушение.
  • Нормализаторът е консервативен (company-name-key.ts) — сгъва само презентационен шум, пази правната форма и всеки различаващ токен, третира кирилско/латинските хомоглифи и „и"/„&" като различни (уклон към пропуск, никога към over-merge), пази празните ключове.
  • TLS без глобален bypass (tls.mjs) — rejectUnauthorized:false е ограничен до host-scoped агента; NODE_TLS_REJECT_UNAUTHORIZED не се пипа никъде. Leaf-SPKI пинът се проверява при всеки socket, fail-closed при разминаване или липсваща насрещна верига, преди тялото да се чете. По-строго от доверие в публичния CA.
  • XXE-safe (parse.mjs) — fast-xml-parser не разрешава външни entity-та, а вход с DOCTYPE/ENTITY се отхвърля изрично.
  • Публикационни инварианти 1/3/4. Всяка публична заявка филтрира status='published' AND interest_class IN ('private_ownership','family_ownership'); класовете, които не се показват (ex_officio/management), получават status='internal' още в D1. Стойността и подредбата са спрямо декларирания период (contemporaneous), не за целия живот на връзката. Rate-лимитът е fail-closed в прод.
  • Схема 0002 — идемпотентна (14× CREATE … IF NOT EXISTS, без INSERT/ALTER/DROP), без сблъсък с 0000, коректни FK-ове; ship-ът има долен праг срещу изтриване, коректен ред на изтриване спрямо FK, без SQL инжекция, а таблицата с лични данни се трие, но никога не се публикува.

Находки

🔴 HIGH (CI) — прод-деплоят се къса на празно SIGMA_D1_NAME. deploy.yml:138 подава името позиционно: wrangler d1 execute "$SIGMA_D1_NAME" …. За прод SIGMA_D1_NAME е незададена (генерирането ѝ дава sigma — docs/deploy.md:58), credential-проверката не я гледа (deploy.yml:92-94), а guard-ът за имена е пропуснат за прод (:106). Значи стъпката тръгва с празен позиционен аргумент → d1 execute "" дава грешка → зависимата „Deploy explorer" се пропуска → целият прод релийз пада. Staging минава (задава sigma-stage), затова се вижда чак на първия прод деплой. Другите стъпки ползват генерираната конфигурация (database_name:"sigma"), затова тях не ги засяга. → Поправка: wrangler d1 execute "${SIGMA_D1_NAME:-sigma}" (същият fallback към sigma, както при генерирането), или добави SIGMA_D1_NAME към credential-проверката.

🔴 HIGH (дизайнерска дилема, не бъг) — връзката „източник" при семеен дял води до документа, който назовава свързаното лице. Подзаявката за source_url (related-persons.ts:86-88) няма ограничение по scope/клас, така че и family_ownership връзка получава активна връзка (ConflictCards.tsx:158-160) към декларацията (https://register.cacbg.bg/<folder>/<xml>, load.mjs:223) — а това е точно публичният документ, в който името и роднинската връзка на свързаното лице са изписани. Осъзнато и описано е (conflict.methodology.tsx:118-120: „не го възпроизвежда на страницата, но и не крие публичния първоизточник"), но е в пряко напрежение с инвариант 2, който обещава „никога името … на свързаното лице". За инструмент, който анонимизира близките именно за да не уврежда частни лица, един клик до документа, който ги назовава, си струва изрично продуктово решение. За self връзките е коректно (източникът назовава самото длъжностно лице). → Ако анонимността трябва да е абсолютна: CASE WHEN il.interest_class='family_ownership' THEN NULL ELSE (…) END AS source_url, плюс проверка и в ConflictCards.

🟡 MEDIUM (сигурност) — деструктивният data workflow няма предпазителя за прод-имена, който deploy.yml има. ship-related-persons.mjs изтрива всички „свързани лица" таблици и презарежда; кръст-средовата защита стъпва само на SIGMA_D1_ID. Ако dev/staging Environment е конфигуриран с прод-ското SIGMA_D1_ID (или SIGMA_D1_NAME=sigma), „dev" ребилд изтрива и презарежда прод D1-а — а тук няма guard, който да отхвърли прод-имена на не-прод пускане (за разлика от deploy.yml:105-122). По-деструктивният workflow има по-слабата защита. → Добави същия guard (не-прод да не ползва sigma) преди ship стъпката.

🟢 LOW (fail-open) — interest_class пада към клас, който се показва публично. 0002…sql:77: interest_class NOT NULL DEFAULT 'private_ownership', докато status пада към held (fail-closed). Днес няма теч (load.mjs винаги задава класа), но асиметрията е fail-open — бъдещ writer, който сложи status='published' и забрави класа, ще изведе реда на публичната повърхност. → Стойност по подразбиране, която не се показва (напр. management_role).

🟢 LOW / PLAUSIBLE (зависи от шаблона) — fallback-ът за колони в парсера може да сгреши при невиждан вариант на таблицата. parse.mjs:110-122 намира колоните първо по описание, с fallback към фиксирани номера, които съвпадат с текущия познат шаблон (авторът изрично знае за риска — коментарът). Ако CACBG смени шаблона така, че описанията спрат да съвпадат И редът на колоните се измести, погрешно разчетена holder-колона води до holderRelation='self' (:122) — семеен дял става собствен на длъжностното лице (по-силно твърдение). Всички fixture-и имат описания, значи този път е нетестван. → Fail-closed при fallback + fixture без описания.

🟢 LOW (defense-in-depth) — ЕИК се приема само по формат (без mod-11 контролна цифра). За да се публикува, ЕИК-ът трябва точно да съвпадне с валиден победител и да мине проверката по име, така че контролната цифра не добавя защита срещу over-merge, каквато cross-check-ът вече не дава. → mod-11 като допълнителен gate.

🟢 LOW (коментар) — ship-related-persons.mjs:5 казва, че деплоят пуска d1 migrations apply, а всъщност ползва d1 execute --file (нарочно, за да не се пусне 0000 наново). → Оправи коментара.

Заключение

Силна, добре защитена основа — защитата срещу клевета е закалена и издържа adversarial проверка. Преди merge: HIGH-ът в CI е реален блокер за прод-деплой; дилемата със семейния „източник" иска изрично продуктово решение (не е бъг, но е точно инвариантът, който инструментът пази); MEDIUM-ът за guard-а на деструктивния workflow е евтина застраховка. Останалото е defense-in-depth. За чернова — впечатляващо ниво на строгост.

Comment thread .github/workflows/deploy.yml Outdated
Comment thread packages/db/src/queries/related-persons.ts Outdated
@cefothe

cefothe commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

🇧🇬 Ревю на PR #226 — „свързани лица"

Обобщение: Одобрявам основата. Изключително стабилна инженерна работа за клеветнически чувствителна област. Композитна оценка 8.8/10. Няма блокиращи проблеми (Critical/High). Тъй като е чернова с отложен Phase-0 гейт върху реалния корпус, финалната препоръка е COMMENT — да се слее след потвърждаване на числата.

Сигурност (9/10) ✅

  • Никакви твърдо кодирани тайни — реферират се само имена на GitHub secrets.
  • TLS пиниране (scripts/cacbg/tls.mjs): host-scoped SPKI pin на листовия сертификат, без глобален bypass, fail-closed, без TOCTOU, повторна проверка при keep-alive преизползване. Стриктно по-строго от доверие към публичния CA.
  • SQL инжекция: параметризирани заявки навсякъде; FTS5 MATCH сведен до prefix токени (с тест срещу инжекция); единственият raw-SQL път (sqlValue) премахва NUL и удвоява кавичките; предпазител assertShipFloor срещу случайно изтриване.
  • Path инжекция: safeFolder/safeXmlFile/safeYear отхвърлят traversal/абсолютни пътища (адверсариални тестове).
  • Ограничаване на достъпа: /conflicts* лимитерът отказва затворено (fail-closed) в prod и покрива .data близнака — не може да се заобиколи за масов експорт на имена.
  • PII: ЕГН се маха при парсване; имена на близки никога не влизат в базата или публичната повърхност (ADR-0010/0022/0023).

Архитектура (8.5/10)

  • Нормализаторът на имена като единствена клеветническа повърхност, изолиран и 100% тестван — правилното решение за компонент с правни последици.
  • 22 съгласувани ADR-а; ADR-0027 честно коригира предишен „структурен фалшив нул" — точно тази прозрачност е нужна тук.
  • Read-time contemporaneous split (ADR-0024) е обоснован при ограничен, кеширан набор.

База данни (8.5/10) · Производителност (8.5/10)

  • Индексното покритие отговаря на всеки път за достъп; миграция 0002 е идемпотентна (IF NOT EXISTS), приложена преди деплой на Worker-а.
  • Search fan-out е паралелен (Promise.all), с ранно прекъсване при 0 съвпадения — без регресия per-keystroke.

Тестове (9/10) · Документация (9.5/10)

  • 0 over-merge доказан с твърди негативи (АЛФА ЕООДАЛФА АД, кирилица/латиница, вътрешни кавички).
  • SQL тестовете изпълняват точния експортиран SQL върху продукционните миграции в реален SQLite.
  • Методологичната страница честно оповестява пропуските в обхвата (непряко/HoldCo владение, смяна на име, кирилица/латиница) — всички са безопасни false-negative.

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

  1. Phase-0 гейтът (over-merge=0, auto-match rate, размер на двусмислената опашка върху реалния корпус) да е явен чеклист/issue, не само проза.
  2. Индекс idx_interest_links(person_id, bidder_id) преди растеж на набора.
  3. LEADERBOARD_SQL: keyset/CTE, ако наборът наближи тавана от 1000.
  4. Дребни фронтенд: type-narrowing вместо l.sourceUrl!; ключ contractSlug вместо индекс на масива; ?page=0 → страница 1 без URL синхронизация.
  5. Да се документира разминаването в Wrangler migration tracking и как ще се прилага 0003_*.
  6. Един тест за FTS инжекция за новия official вид търсене.

Заключение: Отлична, клеветнически безопасна по конструкция основа. Одобрявам за сливане след потвърждаване на Phase-0 числата срещу реалния корпус. 👏

🤖 Генерирано ревю с паралелни специализирани агенти (сигурност, архитектура, БД, фронтенд, производителност, тестове/документация).

lyubomir-bozhinov added a commit to lyubomir-bozhinov/sigma that referenced this pull request Jul 13, 2026
…st_class default

Addresses the two HIGH/MED findings from the midt-bg#226 review (nedda76):

- deploy.yml: default SIGMA_D1_NAME to `sigma` in the schema-apply step. Prod
  leaves the var unset (uses the committed default), so a bare "$SIGMA_D1_NAME"
  ran `d1 execute ""` and aborted the first prod release (HIGH).
- related-persons-data.yml: reject the prod D1 name `sigma` on non-prod
  dispatches. This rebuild wipes+reloads the свързани-лица tables but lacked the
  non-prod name guard deploy.yml already has — a misconfigured dev/staging run
  could wipe prod (MEDIUM).
- ship-related-persons.mjs: resolveD1Name() refuses the `sigma` default on a
  --remote ship with an unset SIGMA_D1_NAME (root cause of the prod-wipe
  footgun; --local keeps the default). Unit-tested.
- 0002 migration: interest_class DEFAULT private_ownership -> management_role, a
  non-surfaced class, so a future writer that sets status but omits the class
  cannot leak to the public surface (fail-closed; load.mjs always sets it).
…st_class default

Addresses the two HIGH/MED findings from the midt-bg#226 review (nedda76):

- deploy.yml: default SIGMA_D1_NAME to `sigma` in the schema-apply step. Prod
  leaves the var unset (uses the committed default), so a bare "$SIGMA_D1_NAME"
  ran `d1 execute ""` and aborted the first prod release (HIGH).
- related-persons-data.yml: reject the prod D1 name `sigma` on non-prod
  dispatches. This rebuild wipes+reloads the свързани-лица tables but lacked the
  non-prod name guard deploy.yml already has — a misconfigured dev/staging run
  could wipe prod (MEDIUM).
- ship-related-persons.mjs: resolveD1Name() refuses the `sigma` default on a
  --remote ship with an unset SIGMA_D1_NAME (root cause of the prod-wipe
  footgun; --local keeps the default). Unit-tested.
- 0002 migration: interest_class DEFAULT private_ownership -> management_role, a
  non-surfaced class, so a future writer that sets status but omits the class
  cannot leak to the public surface (fail-closed; load.mjs always sets it).
Bring the upstream PR branch's deploy.yml to parity with the fork on two
non-deploy-layer hardening bits it was missing:

- echo "::add-mask::$key" before `wrangler secret put LOG_IP_KEY` — registers
  the generated key with the runner so an accidental set -x / echo in a later
  edit cannot leak it (masking is not retroactive within a line).
- timeout-minutes: 10 on the deploy job — fail fast on a wedged run instead of
  burning a 6-hour slot; the required-reviewers wait does not count against it.

The `dev` environment target + its provisioning docs stay fork-only (that is
genuine deploy-layer). Surfaced while reflecting the midt-bg#226 review fixes.
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator Author

@todorkolev — изскача продуктово решение, което искам да маркирам преди merge (не е бъг; @nedda76 го повдигна в ревюто си и е основателно).

Въпросът. При семеен дял (family_ownership) повърхността показва длъжностното лице + фирмата + стойността, а близкият е анонимизиран като „свързано лице" (името му никога не влиза в базата — инвариант 2). Но картата дава линк „декларация" към първоизточника в Публичния регистър на Сметната палата (register.cacbg.bg/<folder>/<xml>) — а точно този документ изписва името и роднинската връзка на близкия. Анонимизираме на страницата, но един клик до източника го назовава.

Съзнателно е — отбелязано в кода (conflict.methodology.tsx, §„Първоизточник"): „не го възпроизвеждаме на страницата, но не крием публичния първоизточник". Компромисът е реален: прозрачност на публичния източник vs. абсолютна анонимност на частното лице.

Опции.

  1. Пази линка (текущо) — източникът е публичен, методологията го оповестява; близкият остава без име в нашия UI.
  2. Скрий линка само за familyCASE WHEN interest_class='family_ownership' THEN NULL …; при self връзки линкът остава (там източникът назовава самото длъжностно лице). Едноредова промяна + guard в UI-я.

Няма технически блокер и в двата случая — това е политически/правен избор за инструмент, който анонимизира близките именно за да не уврежда частни лица. Твоят call; ако е опция 2, влиза в fix batch-а.

(HIGH-1 в CI и MEDIUM guard-ът за прод вече са поправени в кода — това тук е само за анонимността.)

@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: „свързани лица — детерминистична основа за данни за конфликт на интереси"

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

PR-ът изгражда детерминистична основа за данни за конфликт на интереси („свързани лица"): CACBG/TR crawler и loader-и, нормализация на имена, миграция 0002 (interest_links, link_suppressions), параметризирани заявки, precompute/refresh SQL слой, ship-скрипт към D1, фронтенд („Свързани лица", конфликтни карти, търсене), edge-worker логика (rate-limit, noindex, cache-tag), CI workflows и обширна ADR документация (0007–0028 + spec). Подходът е умишлено детерминистичен (без евристики), с publish/held/quarantine нива, anti-libel и anti-де-анонимизационни предпазители, и силна PII дисциплина.

Обща оценка

Качеството е последователно високо през всичките 8 партиди: изчистен и добре документиран код, смислени (не тривиални) тестове, които защитават реални libel/data-integrity инварианти, и умишлено силна поза по сигурност.

Сигурност (Фаза 0) — ЧИСТО във всички партиди: няма твърдо кодирани тайни (SPKI pin-овете и LOG_IP_KEY са коректно третирани), няма злонамерени шаблони/backdoor/обфускация, външните хостове са само легитимни български държавни регистри. SQL е параметризиран (.bind()/prepare(?)) или статичен; няма shell инжекция (execFileSync + argv); XXE е блокиран (assertNoDoctype); path traversal е санитизиран и адверсариално тестван; TLS pinning е по-строг от доверяване на публична CA; FTS5 инжекция е покрита. GitHub Actions са пиновани по SHA.

Най-важни находки

Блокиращи / изискват потвърждение преди merge:

  1. link_key инвариант (партиди 2 и 4) — най-съществено. eik се взима директно от пътя без формат-валидация и се вгражда в link_key. URL-кодиран | (%7C) позволява self-ендпойнтът да сервира family-обхвата, нарушавайки инвариант, който коментарите обявяват за невъзможен. Засяга и defamation-чувствителния път за поправки/сваляния (link_suppressions). Препоръка: валидирайте eik с ^\d{9,13}$ (404 иначе) преди строене на ключа; сверете дефиницията на ключа с load.mjs. Партида 4 маркира това като REQUEST_CHANGES.
  2. Нова зависимост fast-xml-parser@^5.9.3 (партиди 4, 5) — изисква човешко одобрение. Lockfile-ът съвпада с upstream, пакетите са first-party (не typosquat), без install-hooks — но веригата нараства от 1 на 6 много нови, слабо разпространени под-пакета, а caret-диапазоните допускат бъдещо издърпване на непрегледан код. Потвърдете нуждата и scope-а (изглежда като root devDependency — грешно място ако ingest го ползва в runtime), пинирайте точни версии/overrides и наложете --frozen-lockfile/pnpm audit в CI.

Препоръчани преди merge (неблокиращи, но важни):
3. Не-атомарен wipe→insert в ship-скрипта (партида 8). Провал по средата оставя обслужващата повърхност празна/частична без rollback. Препоръка: post-ship проверка на брой published връзки и/или документиран rollback в runbook.

За потвърждение (незадължителни):

  • Покритие на чистите функции в conflicts.ts за ≥90% бариера (партида 2).
  • Дублиран SQL в search-sql.test.ts (собствено копие вместо четене на продукционния SQL → drift, фалшива увереност) — партида 5.
  • Fail-closed 503 засяга и некеширана публична /conflicts/methodology; noindex върху цялата /search* повърхност — потвърдете умисъла (партида 3).
  • .data близнаци на страници за компании/лица извън /conflicts — възможна експозиция от същия клас (партида 3).
  • authorityShares взима тотала от първия ред; did без folder-namespace; TLS single-pin с изтичане ~2027-01 (нужен мониторинг); дребни нормализации на eik.

Заключение

Няма блокиращи проблеми по сигурност или цялост в нито една партида. Двата елемента за задължително адресиране/потвърждение преди merge са: (1) валидация на eik/link_key инварианта и (2) човешко одобрение на веригата зависимости fast-xml-parser; силно препоръчително е и (3) rollback/верификация след ship. След тяхното изясняване PR-ът е готов за одобрение. Забележка: acceptance-критериите срещу описанието на тикета не са автоматично сверени (MCP конекторите не са оторизирани) — препоръчва се ръчна сверка преди merge.

Comment thread .github/workflows/related-persons-data.yml Outdated
Comment thread apps/web/app/components/ConflictCards.tsx
Comment thread apps/web/workers/app.ts
Comment thread apps/web/workers/conflicts-rate-limit.ts
Comment thread packages/db/migrations/0002_related_persons_foundation.sql Outdated
Comment thread packages/db/src/queries/related-persons.ts
Comment thread package.json Outdated
Comment thread scripts/ship-related-persons.mjs
Comment thread scripts/ship-related-persons.mjs
Comment thread scripts/ship-related-persons.mjs
lyubomir-bozhinov added a commit to lyubomir-bozhinov/sigma that referenced this pull request Jul 14, 2026
Addresses the midt-bg#226 review (ydimitrof).

- NOT_REDUNDANT_FAMILY and its mirrors (precompute.sql, refresh-slice.sql, and
  the search-sql test copy) collapsed the redundant family link by
  (person_id, bidder_id). That was correct only via the loader's implicit
  eik->bidder_id 1:1 (winners are keyed id='eik:'||eik). Key the collapse on
  (person_id, eik) instead, so the libel-critical ADR-0023 de-anonymisation
  guard is correct-by-construction, independent of the bidders-id scheme.
  Behaviour-identical on real data; a new adversarial test with a duplicate-eik
  bidder proves it discriminates (RED on the old bidder_id predicate).

- The 0002 link_key comment documented only the self form (pid|eik); a family
  link's key carries a |family suffix (load.mjs), so the self+family rows are
  distinct and UNIQUE holds. Corrected the comment and added a family-suppression
  round-trip test: a takedown keyed pid|eik|family must survive re-import, or a
  family (defamation-sensitive) suppression silently no-ops.
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator Author

@ydimitrof Благодаря за детайлното ревю — минахме през всяка находка. Какво предприемаме и какво изяснихме:

Адресираме (отделни commit-и, първо във форка):

  1. Колапсът срещу де-анонимизация да е по eik, не по bidder_id. Точна бележка — NOT_REDUNDANT_FAMILY (и копията му в precompute.sql / refresh-slice.sql / теста) днес работят само защото loader-ът кара eik → bidder_id да е 1:1 (победителите се ключват id='eik:'||eik), но това е имплицитна лема на libel-критичен път. Сменяме на s.eik = il.eik (correct-by-construction) + адверсариален тест със сценария ти — един eik върху два реда в bidders; RED на стария предикат.
  2. Коментарът за link_key (0002.sql:58) беше непълен. Кодът е коректен и асиметричен — self = pid|eik, family = pid|eik|family (load.mjs), затова UNIQUE не се чупи и колапсът не е мъртъв код. Но коментарът документираше само self-формата; коригирахме го + регресионен тест, че потискане по pid|eik|family съвпада след re-import (точно рискът за тихо проваляне на family сваляне, който посочваш).

Изяснено — без промяна:

  • eik %7C: в най-лошия случай сервира вече публичен, анонимизиран family ред — никаква PII/libel експозиция; заявките са параметризирани (il.eik = ? / il.link_key = ?). ^\d{9,13}$ валидация — само като хигиена, ако прецениш.
  • fast-xml-parser: внася се само в scripts/cacbg/parse.mjs (ETL), не влиза в бандъла на прод worker-а; веригата е одитирана (ADR-0025) + --frozen-lockfile навсякъде.

Останалото (не-атомарен ship, ConflictCards error state, oversized-batch guard) — follow-up. Промените минаха adversarial валидация във форка (RED→GREEN + пълна регресия) и ги отразяваме тук.

Addresses the midt-bg#226 review (ydimitrof).

- NOT_REDUNDANT_FAMILY and its mirrors (precompute.sql, refresh-slice.sql, and
  the search-sql test copy) collapsed the redundant family link by
  (person_id, bidder_id). That was correct only via the loader's implicit
  eik->bidder_id 1:1 (winners are keyed id='eik:'||eik). Key the collapse on
  (person_id, eik) instead, so the libel-critical ADR-0023 de-anonymisation
  guard is correct-by-construction, independent of the bidders-id scheme.
  Behaviour-identical on real data; a new adversarial test with a duplicate-eik
  bidder proves it discriminates (RED on the old bidder_id predicate).

- The 0002 link_key comment documented only the self form (pid|eik); a family
  link's key carries a |family suffix (load.mjs), so the self+family rows are
  distinct and UNIQUE holds. Corrected the comment and added a family-suppression
  round-trip test: a takedown keyed pid|eik|family must survive re-import, or a
  family (defamation-sensitive) suppression silently no-ops.

@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: „свързани лица — детерминистична основа за данни за конфликт на интереси"

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

PR-ът въвежда детерминистична основа за данни за конфликт на интереси на длъжностни лица („свързани лица"). Обхваща целия конвейер — от извличане и парсване до публикуване и представяне:

  • ETL/скриптове (scripts/cacbg/*, scripts/ship-related-persons.mjs): fetch с TLS certificate pinning, XML парсър със защита срещу XXE, санитайзери срещу path traversal, детерминистичен резолвър, census/quarantine логика и отделен ship-скрипт от work-SQLite към обслужваната D1.
  • Данни (0002_related_persons_foundation.sql, queries/*, api-contract DTO-та): миграция, параметрични заявки за връзки/идентичност/търсене, precompute/refresh-slice SQL.
  • Публична повърхност (Cloudflare worker, ConflictCards.tsx, SmartSearch, CSS): rate-limiting срещу enumeration, noindex за именни страници, достъпен UI компонент, поправка на DEPLOY_TAG.
  • Инфраструктура/документация: GitHub Actions workflow-и (pinned към commit SHA), обширна верига ADR-и (0007→0025) с ясна fail-closed позиция по libel-риск и PII.

Обща оценка

Висококачествена, добре документирана и добре тествана промяна със силна защитна култура — грижа за сигурност, PII-релси и защита от клевета в целия конвейер. Всичките 8 партиди дадоха вердикт COMMENT — няма открити критични уязвимости и няма блокиращи проблеми.

Сигурност (Phase 0 — CLEAN във всички партиди)

  • Няма хардкоднати тайни, backdoor-и или обфускация. CACBG_SPKI_PIN е публичен ключов отпечатък; тайните минават през GitHub Environment secrets.
  • SQL/FTS/команден инжекшън: продукционният код е изцяло параметризиран; sqlLiteral/sqlIdent екранират коректно; execFileSync (без shell) предотвратява command injection.
  • XSS/XXE: isHttpsUrl отхвърля javascript:/data:; парсърът отхвърля DOCTYPE/ENTITY.
  • TLS pinning: ограничено до един host с ръчна SPKI верификация, fail-closed.
  • PII/libel: анонимизацията на свързаните лица е структурно наложена; имената на роднини съзнателно се изключват от публичната D1 (покрито с тест); de-anonymization векторът е затворен.
  • Нова зависимост fast-xml-parser@^5.9.3 и транзитивните ѝ пакети са проверени — легитимна модуларизация от същия автор, без install-скриптове (некритична бележка: пакетите са нови и single-maintainer).

Най-важни находки за потвърждение преди merge (некритични)

  1. Cross-folder колизия на имена в load.mjs (най-съществено): декларацията се ключира само по базово име (decl:${h.xmlFile}). Еднакво име в различни folder-и може при INSERT OR IGNORE да закачи интереси към чуждо лице — точно грешната атрибуция (libel-adjacent), която PR-ът цели да избегне. Препоръка: ключиране по folder+xmlFile (или controlHash) + тест за колизия.
  2. Съгласуваност на €-сумите: формулата за сумата на „официал" в precompute.sql (lifetime contract_value_eur) изглежда различна от refresh-slice.sql (contemporaneous прозоречна сума). Да се потвърди, че двете изчисляват идентична стойност, за да не се промени публичната цифра след refresh.
  3. DEPLOY_TAG в worker бъндъла: да се потвърди, че Vite define реално достига worker пасажа, иначе typeof guard-ът тихо се връща към Date.now().
  4. Извеждане на rate-limit ключа: поведението при липсващ CF-Connecting-IP да е потвърдено (споделен vs. празен ключ = заобикаляне), тъй като това е единственият anti-enumeration контрол.
  5. Case/whitespace чувствителност в parse.mjs: сравнението holder === declarant без нормализация би могло да класифицира СОБСТВЕН дял като „related" → фалшив family_ownership линк. Препоръка: trim + case-fold преди сравнение.

Дребни забележки

  • related.jsonl цикълът в load.mjs да приложи empty-name guard-а, ползван при holdings.
  • fetch.mjs: да се валидират --limit/--concurrency; затваряне на write-streams/DB в finally.
  • Паразитна дума „ponytail" в коментара на LINK_SELECT да се махне.
  • Дублиране на блока за длъжностни лица между precompute.sql и refresh-slice.sql (риск от разминаване); неизползвани JOIN-ове да се документират или премахнат.
  • Неатомарен ship (ship-related-persons.mjs): при провал между wipe и re-insert живата D1 остава празна/частична — да се документира процедура за възстановяване при --remote.
  • Некритични: липса на retry при fetch в ConflictCards; тестови fixtures интерполират низове в SQL (само тест, анти-паттерн).

Заключение

Няма блокиращи проблеми. Кодът е готов за merge по същество; препоръчва се да се адресира или потвърди списъкът по-горе — с приоритет т.1 (cross-folder колизия) и т.2 (съгласуваност на €-сумите) — преди финален APPROVE. Отлична дисциплина по провенанс, PII и libel-защита.

Comment thread apps/web/app/components/ConflictCards.tsx
Comment thread .github/workflows/deploy.yml
Comment thread apps/web/vite.config.ts
Comment thread apps/web/workers/conflicts-rate-limit.ts
Comment thread apps/web/workers/app.ts
Comment thread scripts/cacbg/parse.mjs Outdated
Comment thread scripts/precompute.sql
Comment thread scripts/refresh-slice.sql
Comment thread scripts/ship-related-persons.mjs
Comment thread scripts/ship-related-persons.mjs
lyubomir-bozhinov added a commit to lyubomir-bozhinov/sigma that referenced this pull request Jul 14, 2026
Addresses the midt-bg#226 review (ydimitrof).

parseAssets classified a stake as a relative's (family_ownership) whenever the
holder-name cell didn't EXACTLY match <Personal><Name>. But the holder column is
hand-typed and, for a self stake, repeats the declarant's own name — any casing
or spacing drift ("ИВАН  ПЕТРОВ ТЕСТОВ" vs "Иван Петров Тестов") flipped a SELF
stake to a fabricated family_ownership link, asserting a non-existent relative's
holding on the libel-sensitive public surface (the misattribution this feature
exists to avoid; ADR-0023).

Compare on a case-folded, whitespace-collapsed nameKey instead. Test-first: a
RED test pins that a self stake whose holder repeats the own name with case/space
drift stays 'self' with no fabricated family holding.
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator Author

@ydimitrof Благодаря — минахме през целия списък. Едно приех и поправих; за двете „за потвърждение" ето какво показва кодът:

Поправено — #5 (holder↔declarant). Права бележка. parseAssets правеше дял роднински при всяко несъвпадение на holder-клетката с <Personal><Name>, а колоната е ръчно попълвана и при собствен дял повтаря собственото име — всяко разминаване в регистър/интервали създаваше фалшив family_ownership линк с несъществуващ роднина. Вече сравняваме по case-fold + свити интервали (nameKey), RED-first тест. commit c94d78b.

#1 cross-folder колизия — не се потвърждава като misattribution. Cross-person грешна атрибуция изисква регистърът да преизползва xmlFile между различни лица. Емпирично на живо xmlFile е GUID (напр. 515FA0BB-…-…218471.xml) → глобално уникален, така че колизия между лица практически няма. И кодът не я допуска: extract.mjs дедупва по controlHash; естественият ключ UNIQUE(xml_file, control_hash) е за версиониране на СЪЩАТА декларация при поправено подаване (виж коментара в extract.mjs). Реалистичният сблъсък (същото лице, поправено подаване) сваля само метаданни — без грешна атрибуция. Съгласен съм, че PK decl:${xmlFile} е по-строг от естествения ключ — мога да сложа controlHash в ключа като robustness, но това не е libel бъг.

#2 €-суми — идентични. Продукционните precompute.sql и refresh-slice.sql са байт-за-байт еднакви и двата смятат contemporaneous прозоречната сума, не lifetime — публичната цифра не мърда след refresh. Разминаването е само в ТЕСТОВОТО огледало (search-sql.test.ts POPULATE_INDEX, опростен SUM(il.contract_value_eur)); мога да изравня теста, но продукцията е консистентна.

Останалото: не-атомарен ship — осъзнат компромис, документиран в кода (recovery = повторен ship), follow-up. noindex за /conflicts/methodology/x — такъв път няма (единична котва). Липсващ CF-Connecting-IP — Cloudflare винаги го подава на edge; тестова празнина, не runtime дупка. fast-xml-parser — сам го потвърди (ETL-only). ConflictCards retry-при-провал — приемам като реален дребен UX, follow-up.

Проверих и живата повърхност (preview): анонимизацията на близките държи (никакво име в DOM/.data), .data близнакът носи noindex и не издава нищо повече от HTML.

Addresses the midt-bg#226 review (ydimitrof).

parseAssets classified a stake as a relative's (family_ownership) whenever the
holder-name cell didn't EXACTLY match <Personal><Name>. But the holder column is
hand-typed and, for a self stake, repeats the declarant's own name — any casing
or spacing drift ("ИВАН  ПЕТРОВ ТЕСТОВ" vs "Иван Петров Тестов") flipped a SELF
stake to a fabricated family_ownership link, asserting a non-existent relative's
holding on the libel-sensitive public surface (the misattribution this feature
exists to avoid; ADR-0023).

Compare on a case-folded, whitespace-collapsed nameKey instead. Test-first: a
RED test pins that a self stake whose holder repeats the own name with case/space
drift stays 'self' with no fabricated family holding.

@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: „Свързани лица — детерминистична основа за данни за конфликт на интереси"

Обобщение

PR въвежда read-only, детерминистичен слой за „свързани лица" за целите на разкриване на конфликт на интереси. Обхватът включва: DTO-та и миграция 0002, заявки (related-persons.ts, разширение на търсенето, slug-помощници), CACBG pipeline (fetch/parse/load/guard/tls), TR-census, precompute/refresh SQL, ship-скрипт от работна SQLite към обслужваната D1, обширни интеграционни и юнит тестове, седем ADR-а и спецификация. Кодът е с високо инженерно качество, добре документиран, детерминистичен/идемпотентен и с внимателно обмислена libel/PII позиция.

Сигурност (Phase 0 + OWASP) — ЧИСТО във всички партиди

  • Няма hardcoded тайни, backdoor-и, обфускация или code injection. CACBG_SPKI_PIN е публичен SPKI отпечатък (не тайна) с документирана out-of-band ротация.
  • SQL инжекция: всички продукционни заявки са параметризирани (.bind() / prepare().run()); статичните SQL фрагменти не интерполират потребителски вход; ship-скриптът коректно екранира на границата към D1 (sqlIdent/sqlLiteral, покрити с тестове). Стрингова интерполация има само в тестови фикстури с контролирани стойности.
  • Няма shell injection (само execFileSync с масив от аргументи), path traversal е спрян от guard.mjs, XXE е предотвратен (fast-xml-parser без DTD/external entities + assertNoDoctype).
  • TLS pinning е fail-closed и по-строг от доверие към публично CA; глобалната TLS верификация НЕ е изключена.
  • PII rail (§8): имената на трети лица/роднини никога не се персистират в публичната повърхност; interest_class е fail-closed; де-анонимизиращият existence-oracle е затворен (ADR-0023); partial-census libel-gate отказва да фабрикува „false-unique" твърдения.
  • Нова зависимост fast-xml-parser@^5.9.3: проверена като легитимна (истински maintainer, без install-скриптове), но въвежда нова верига от малко популярни транзитивни пакети — изисква еднократно човешко одобрение преди merge.

Качество и тестове — силно

Тестовете са смислени, разкриват реални дефекти (libel/false-attribution, де-анонимизация, contemporaneous прозорец, FK ред при reseed с негативни контроли) и не са тривиални. Миграционната верига е приложена коректно.

Открити забележки (не-блокиращи, за адресиране/уточнение преди merge)

  1. fetch.mjs — circuit breaker пропуска устойчиви HTTP грешки. consecutive расте само при мрежови throw-ове; стена от 403/429/5xx никога не задейства breaker-а. Препоръчва се да се отчита и в non-200 клона. (Най-съществената забележка за устойчивост.)
  2. Мъртви INNER JOIN-ове в amount под-заявката (precompute.sql, refresh-slice.sql): JOIN tenders/JOIN authorities не се използват и могат тихо да отпаднат договори без свързан ред → подценяване на сумата. Да се потвърди референтна цялост или да се премахнат.
  3. Инвариант брой vs. сума на договорите: contemporaneous_contract_count е COUNT(*) (включва amount_eur IS NULL), докато сумата пропуска NULL — тестът за инвариант да покрива и брой, за да не се появи „20 от 15 договора".
  4. Обхват по bidder: contemporaneous под-заявките съединяват по eik_normalized, а главната сума по bidder_id — да се потвърди инвариантът „един ЕИК → един winner ред".
  5. load.mjs: липсва nameless-guard при вмъкване на person от related.jsonl (риск от сливане на декларанти във вътрешната PII таблица); възможна колизия на declaration id при преизползвано XML име между папки (provenance разминаване); повтарящо се db.prepare в related цикъла (дребна перф.).
  6. tr-census.mjs: readFileSync + JSON.parse върху пълен TR dump рискува OOM — да се обмисли streaming при full-register run.
  7. Дребни: нисък ReDoS риск в extract-companies.mjs; булеви footgun-и в arg() парсването на ship-скрипта; lockfile дисциплина (--frozen-lockfile във всички workflow-и); зависимост на интеграционните тестове от sqlite3 CLI и експерименталния node:sqlite.

Заключение

Няма открити блокиращи проблеми по сигурност, инжекция, целостност на данните или зловреден код в нито една партида. Кодът е отбранително написан с ясни PII предпазни механизми. Преди merge се препоръчва: (а) човешко одобрение на новата верига транзитивни зависимости на fast-xml-parser, и (б) адресиране/уточнение на т.1 (circuit breaker) и т.2 (мъртви JOIN-ове); останалите са nits.

Общ вердикт: APPROVE с условия — партидите 4–7 са COMMENT в очакване на кръстосаната проверка на изброените инварианти, а партида 8 е APPROVE. Няма блокери; изброените точки са за потвърждение/дообработка.

Comment thread pnpm-lock.yaml Outdated
Comment thread scripts/cacbg/fetch.mjs
Comment thread scripts/precompute.sql
Comment thread scripts/cacbg/tr-census.mjs
Comment thread scripts/ship-related-persons.mjs
Comment thread scripts/ship-related-persons.mjs
Comment thread scripts/ship-related-persons.test.mjs
…g#226)

The crawler skipped a whole set on a non-200 list.xml with a single log line
and counted per-declaration errors without ever failing — a transparency
platform could publish a silently short list, while the bidders side already
fails before the resolver on an export-vs-source mismatch (integrity-checks).

Reconcile announced vs obtained per set: a 404 is a legitimate source gap
(listed-but-unpublished), but a non-404 miss or a wholesale-skipped set is a
real shortfall. Report the numbers and exit non-zero on shortfall, with
--allow-incomplete for a knowing operator override.

Reported by todorkolev on midt-bg#226 (#2).
…t-bg#226)

PRODUCTION_SLOTS was ['sigma','sigma-green'], but the real production slots
are sigma-blue and sigma-green (deploy.md) — there is no slot named 'sigma'.
Because sigma-blue was absent from the list, the "a non-production run may not
name a production slot" guard did not catch it, collapsing two independent
defenses (name + id) to one. The code inherited a stale deploy.md note that
said to keep a slot named 'sigma'; that was never done.

Fix the constant, the workflow env guard, and align the deploy.md note with
its own table. Test: both real prod slots are now refused under a
non-production ship env.

Reported by todorkolev on midt-bg#226 (#3).
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator Author

@todorkolev — и трите са поправени, отразени и на двата PR-а (#226 = #61 byte-parity по feature файловете). Пуснах пълния корпус за числата, които поиска.

Числата (пълен пуск)

Корпус: 162 005 декларации (135 184 имуществени + 26 821 за интереси — 16,6% са за интереси, което покрива изложеното, което ти измери). Bidders база: 17 669 изпълнителя / 195 147 договора — това е build DB-то backfill.sqlite, вероятно поднабор на пълния ЦАИС ЕОП, затова абсолютните стойности растат с базата. Delta-та по-долу е независима от базата: сравнява код срещу идентичен вход.

статус ПРЕДИ (per-person, багът) СЛЕД (per-type)
публикувани 75 (55 свои + 20 семейни) 98 (77 свои + 21 семейни)
задържани (held) 294 340
оттеглени като продадени 248 179
общо връзки 677 677

B1 на пълния корпус: per-person хоризонтът е свалял погрешно 69 връзки. 23 от тях са истински публикувани конфликти (22 свои + 1 семейна) — връщат се. Другите 46 минават от „оттеглена“ в „задържана“. Нула нови оттегляния (published→withdrawn): поправката коригира само в безопасната посока — маха невярно оттегляне, никога не въвежда ново. Точно както го описа: „маха вярна, не задържа съмнителна“.

Върнах и числото в коментара на conflicts.tsx — беше „~292“ от стария по-хлабав pipeline, сега ~98 на текущия строг.

Трите поправки

  1. Хоризонт по вид декларация. filings.jsonl вече носи template; хоризонтът е per (лице, вид). Дял, деклариран в документ от вид T, се оттегля само ако по-късна декларация от вид T го пропуска. Махнах ownMaxByScope (излишен, а в per-type света — вреден). RED тест: interests-only дял оцелява след по-късна само-имуществена декларация; winner→non-winner и divest-to-zero остават оттеглени.

  2. Гейт за пълнота на корпуса. fetch.mjs вече засича обявени срещу изтеглени по набор. 404 е легитимна дупка в източника (обявено-но-непубликувано), но не-404 пропуск или прескочен цял набор е реален недостиг → отчита числото и излиза с ≠0, освен при --allow-incomplete. Симетрично на bidders гейта (integrity-checks.mjs).

  3. PRODUCTION_SLOTS. ['sigma-blue','sigma-green'] — слот sigma няма. Сега sigma-blue се хваща от денилиста за не-продукционен пуск, тоест двете независими защити пак са две. Плюс гейта в workflow-а и бележката в deploy.md, изравнена с таблицата над нея.

§2 ал.3 канарчето мина зелено на пълния пуск (всички материални семейни дялове от 'assets'). CI е зелено и на двата PR-а.

Конкретният случай с точните файлове от регистъра, който спомена — прати ми го, ще го добавя като RED fixture, за да е закован завинаги.

lyubomir-bozhinov added a commit to lyubomir-bozhinov/sigma that referenced this pull request Aug 1, 2026
…idt-bg#226)

The pure decision (assessCompleteness) was unit-tested, but run()'s wiring —
incomplete crawl → non-zero exit — was only eyeballed. Make run() return the
exit code instead of setting process.exitCode internally (a pure, testable
decision; the top-level assigns it to process.exitCode), and inject the I/O
boundary (httpGet, rawDir, argv, scratch guard). getPinned pins the CACBG host
so a fake server is impossible — injection is the only offline seam; the
defaults reproduce production 1:1.

fetch-gate.test.mjs drives the real run() with a fake getter + a temp raw dir:
complete crawl -> 0, a 404 (source gap) -> 0, a non-404 miss -> 1, a
wholesale-skipped set -> 1, --allow-incomplete -> 0; plus a subprocess case
proving the returned code becomes a real non-zero process exit. Mutation-checked
(break the gate / ignore the override → exactly the right cases fail).
…idt-bg#226)

The pure decision (assessCompleteness) was unit-tested, but run()'s wiring —
incomplete crawl → non-zero exit — was only eyeballed. Make run() return the
exit code instead of setting process.exitCode internally (a pure, testable
decision; the top-level assigns it to process.exitCode), and inject the I/O
boundary (httpGet, rawDir, argv, scratch guard). getPinned pins the CACBG host
so a fake server is impossible — injection is the only offline seam; the
defaults reproduce production 1:1.

fetch-gate.test.mjs drives the real run() with a fake getter + a temp raw dir:
complete crawl -> 0, a 404 (source gap) -> 0, a non-404 miss -> 1, a
wholesale-skipped set -> 1, --allow-incomplete -> 0; plus a subprocess case
proving the returned code becomes a real non-zero process exit. Mutation-checked
(break the gate / ignore the override → exactly the right cases fail).
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator Author

@todorkolev — малка добавка по #2: гейтът за пълнота вече е покрит с интеграционен тест, не само на око.

run() вече връща изходния код (чиста, тестваема логика вместо вътрешен process.exitCode), а I/O границата (HTTP getter, raw папка, argv, guard-ът) е инжектируема — getPinned пинва хоста на регистъра, тъй че фалшив сървър е невъзможен, инжекцията е единственият офлайн шев. Тестът кара истинския run() през:

  • пълен обход → изход 0;
  • 404 декларация (обявено-но-непубликувано) → 0 — легитимна дупка в източника, не недостиг;
  • не-404 пропуск → 1;
  • прескочен цял набор (list.xml ≠ 200) → 1;
  • --allow-incomplete0 (сваля недостига до предупреждение);
  • плюс подпроцес, който доказва, че върнатият код става реален ненулев изход на процеса — тества и свързването, не само решението.

Mutation-проверен: счупя ли гейта (return 10) или пренебрегна ли override-а, падат точно съответните случаи; възстановя ли — зелено. Defaults възпроизвеждат продукцията 1:1.

Fork-first, и на двата PR-а, CI зелено.

todorkolev
todorkolev previously approved these changes Aug 3, 2026

@todorkolev todorkolev 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.

Проверих последните два комита (9821207 и bb824a1): гейтът за пълнота вече е и интеграционно тестван, а run() връща изходния код вместо да пипа process.exitCode отвътре - поведението е същото, seam-ът е само за тестове. Трите находки от ревюто ми са поправени, CI е зелен, нишките са затворени.

Мърджваме. Отделно ще отворя issue с описание на следващата стъпка (справки в Търговския регистър като доказателствен слой) и мини-PR за една находка в parseList, която открихме при независима проверка на корпуса.

lyubomir-bozhinov added a commit to lyubomir-bozhinov/sigma that referenced this pull request Aug 3, 2026
conflictHeadline summed contractValueEur/contemporaneousValueEur per link.
NOT_REDUNDANT_FAMILY collapses only a single official's own+family stake in one
winner, so two different officials linked to the same contractor both reach the
array and that winner's money was counted twice (+8.1% / ~7.9M EUR on the full
corpus, midt-bg#226). Aggregate money per eik instead: contract_value_eur is constant
within an eik (exact dedup); contemporaneous_value_eur is a per-link window
subset that can differ between officials on the same winner, so take the max per
eik (deterministic, never overstated). linkCount/officialCount stay per-link and
per-official.

Refs midt-bg#226
conflictHeadline summed contractValueEur/contemporaneousValueEur per link.
NOT_REDUNDANT_FAMILY collapses only a single official's own+family stake in one
winner, so two different officials linked to the same contractor both reach the
array and that winner's money was counted twice (+8.1% / ~7.9M EUR on the full
corpus, midt-bg#226). Aggregate money per eik instead: contract_value_eur is constant
within an eik (exact dedup); contemporaneous_value_eur is a per-link window
subset that can differ between officials on the same winner, so take the max per
eik (deterministic, never overstated). linkCount/officialCount stay per-link and
per-official.

Refs midt-bg#226
lyubomir-bozhinov added a commit to lyubomir-bozhinov/sigma that referenced this pull request Aug 3, 2026
load.mjs's end-of-load JSON summary summed the per-eik contract_value_eur across
published links, double-counting a winner whose stake is declared by two
different officials — the ETL-summary twin of the conflictHeadline fix (midt-bg#226).
Dedup all five money fields per eik via MAX (exact, constant within an eik):
published_contract_value_eur, the private/family value totals, the
by_interest_class value, and the own-institution total (per eik+authority).
Counts stay per-link/per-official. The load test adds two officials on one
winner and asserts the reported total equals the per-eik-deduped sum
(naive minus dedup equals the shared winner's value; mutation-proven).

Refs midt-bg#226
load.mjs's end-of-load JSON summary summed the per-eik contract_value_eur across
published links, double-counting a winner whose stake is declared by two
different officials — the ETL-summary twin of the conflictHeadline fix (midt-bg#226).
Dedup all five money fields per eik via MAX (exact, constant within an eik):
published_contract_value_eur, the private/family value totals, the
by_interest_class value, and the own-institution total (per eik+authority).
Counts stay per-link/per-official. The load test adds two officials on one
winner and asserts the reported total equals the per-eik-deduped sum
(naive minus dedup equals the shared winner's value; mutation-proven).

Refs midt-bg#226

@todorkolev todorkolev 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.

Одобрението ми беше отменено от пуша с поправките, затова го подавам наново върху e3bd701.

Прегледах двата нови комита: дедупликацията по ЕИК в conflictHeadline е точна за contract_value_eur (константна в рамките на ЕИК) и консервативна за contemporaneous_value_eur през MAX - правилният избор, понеже прозорецът е по декларираните години на връзката и наистина може да се различава между две лица за един и същ изпълнител. Близнакът в обобщението на load.mjs беше пропуск от моя страна, благодаря че го хвана.

Всички нишки са затворени, CI е зелен.

@midt-admin
midt-admin dismissed ydimitrof’s stale review August 4, 2026 11:44

Отменено: тялото на това ревю е Not logged in · Please run /login, тоест артефакт от прекъсната сесия на инструмента, а не преценка по същество. Блокиращият проблем от предходното съдържателно ревю (валидация на eik в conflict.contracts.tsx) е поправен на e3bd701 - /^\d+$/ плюс валидация на обхвата. Останалите бележки са пренесени от самия автор в #279.

@todorkolev

Copy link
Copy Markdown
Collaborator

Затварям последните пет нишки от ревюто ми на 30 юли. Проверих всяка срещу e3bd701, а не по описание:

  • suppressions.mjs - версията на ключа е вписана в записа и несъответствието хвърля вместо да мине тихо (SUPPRESSION_KEY_VERSION, ред 50).
  • related-persons-data.yml - SUPPRESSION_SALT вече е вързана в workflow-а (ред 61), а гардът иска sigma-blue|sigma-green за production, тоест разминаването с deploy.md го няма. Секретът обаче не е зададен в нито една среда - това е оперативно, не кодово, и е записано в Регистърни доказателства за връзките „длъжностно лице - дружество" #279.
  • ship-related-persons.mjs - третата независима котва я има: операторът декларира SIGMA_SHIP_ENV и той се сверява срещу списъка по среда, тоест две последователно сгрешени стойности вече не минават. Подът се проверява и при --emit.
  • related-persons.ts - мекият отказ вече гледа кое име липсва (RELATED_PERSONS_TABLES + прихващане на името от грешката), значи изтриване на ядрена таблица не се маскира; добавено е и предупреждение в лога.
  • parse.mjs - третото състояние unknown е налице, тоест неразпознатото не се брои никъде вместо да става фантомен роднина.

С това нерешени нишки няма. Одобрението ми стои върху e3bd701, CI е зелен. Мърджвам.

@todorkolev
todorkolev merged commit 3e76949 into midt-bg:main Aug 4, 2026
5 checks passed
lyubomir-bozhinov added a commit to lyubomir-bozhinov/sigma that referenced this pull request Aug 4, 2026
…ersons)

midt-bg#226 (свързани лица — the conflict-of-interest data foundation) landed on main,
adding a large new surface: packages/db related-persons queries, the /conflicts
routes + ConflictCards, the apps/web conflicts lib and rate limiter, shared
company-name-key, and the scripts/cacbg pipeline.

The merge itself was clean — no conflicts; both overlapping test files
(identity.test.ts, search.test.ts) auto-merged.

The coverage ratchet did fail, though: midt-bg#226 ships its own tests but to a lower
bar than this branch's floors, dropping apps/web to 98.86% lines / 93.01%
branches (floors 99.4/96) and packages/db branches to 97.77% (floor 98.3). Per
the never-lower rule this is fixed with tests — NO baseline floor is touched.
Every case below targets a genuinely reachable branch; nothing is faked and no
source is edited to suit a test.

- ui.test.tsx (new): the shared editorial primitives had no direct test, only
  incidental page coverage of whichever variant that page used. Covers every
  optional-prop branch — Chip tones, ЕИК URL-encoding + rel=noopener, the
  OwnershipChip null/three-kind split, Flag variants, ShareBar clamping outside
  0–1, Callout h3-default vs explicit h2 (heading order) and its variant class,
  Section with and without a hint.
- conflicts.render.test.tsx: an undated + unnumbered + authority-less contract
  (no timeline, „—" body, generic „договор" label, never „№ null"), a
  sub-threshold „под 0,1%" share with no plotted bar, a no-denominator „—"
  share, an empty drill-down, and an all-outside-the-window contract set.
- conflicts.test.ts: unknown temporal tag falls back to the raw value, year-0 /
  non-numeric signing dates are refused by parseYear, and authority-share
  ordering with unknown denominators (plottable first, money breaks a tie).
- conflict.pages.render.test.tsx: meta() on both conflict pages with the loader
  data absent — the error-boundary render must degrade to the generic noun, not
  interpolate "undefined" into a public page title.
- conflicts.loaders.test.ts: route params absent entirely, not merely blank.
- conflicts-rate-limit.test.ts: non-GET/HEAD methods bypass the budget (it
  guards read scraping, and a write is not that vector).
- related-persons.test.ts: a NULL joined authority maps to '' so the card never
  renders a literal null, plus isMissingConflictTableError against a non-Error
  rejection and against a core (non-0003) missing table.

Gate green with no floor lowered: web 99.16/95.84, db 99.82/97.96, etl
100/97.46, ingest 100/98.36, config 100/100, shared 98.87/98.33.
Full suite: config 27, shared 65, ingest 119, etl 40, db 505, web 592.
lyubomir-bozhinov added a commit to lyubomir-bozhinov/sigma that referenced this pull request Aug 4, 2026
…acbg gate midt-bg#281)

Two commits landed on main while the midt-bg#226 sync was in flight — an undici
override bump to ^7.29.0 and a cacbg completeness-gate fix (phantom rows /
--limit). Clean merge; the surface is pnpm-workspace.yaml + scripts/cacbg,
none of it inside the six measured workspaces, so the coverage baseline is
untouched and the gate stays green.

Verified against the merged tree: scripts tests 51 pass, cacbg pipeline tests
84 pass (run via the register-ts loader, as the CI job does), coverage ratchet
green for all six workspaces.
ydimitrof added a commit to ydimitrof/sigma that referenced this pull request Aug 5, 2026
Индексът го отбелязва като заменен от ADR-0030 от PR midt-bg#226 насам, но заглавието
на файла продължаваше да казва „Accepted". Разминаване заглавие/индекс, забелязано
при синхронизирането на индекса за ADR-0033.
ydimitrof added a commit to ydimitrof/sigma that referenced this pull request Aug 5, 2026
The index has marked it superseded by ADR-0030 since PR midt-bg#226, but the file header
still read "Accepted". A header/index divergence, spotted while synchronising the
index for ADR-0033.
ydimitrof added a commit to ydimitrof/sigma that referenced this pull request Aug 5, 2026
The index has marked it superseded by ADR-0030 since PR midt-bg#226, but the file header
still read "Accepted". A header/index divergence, spotted while synchronising the
index for ADR-0033.
LyuboslavLyubenov added a commit to LyuboslavLyubenov/sigma that referenced this pull request Aug 5, 2026
Clean automatic merge: no conflicts. Upstream advanced 3 commits since the
PR's previous rebase (related-persons midt-bg#226, undici bump midt-bg#282, cacbg fix midt-bg#281);
none of those touch the PR's test-only surface (apps/web/test/integration/*,
docs/spec/integration-testing.md, apps/web/vitest.integration.config.ts).
The single auto-merged file is docs/README.md, which gained a new ADR entry
in upstream (0032); the merge preserves the alphabetical/numerical ordering
without re-flowing the PR's content.

Verification (local):
- pnpm typecheck → 7/7 packages clean
- pnpm --filter @sigma/web test → 530 passing (52 files, 8 integration files,
  41 integration tests, 0 .skip)
- pnpm lint → (run separately)
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.

5 participants