Skip to content
Draft
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
a40df20
docs: ADR-0033 — registry evidence replaces the name-distinctiveness …
ydimitrof Aug 5, 2026
4fde377
docs: ADR-0023 carries its superseded status
ydimitrof Aug 5, 2026
ea00781
test(seed): conflict-of-interest smoke fixture for local development
ydimitrof Aug 5, 2026
b364031
feat(tr): registry client, deed cache and the ЕИК checksum as a Node …
ydimitrof Aug 5, 2026
f8b198f
feat(tr): deed parser and the six-rung evidence ladder
ydimitrof Aug 5, 2026
98ac4f5
feat(tr): the deed crawler — paced, resumable, and stopping on a 429
ydimitrof Aug 5, 2026
800b2c1
feat(db): migration 0006 — the Trade Register evidence seal
ydimitrof Aug 5, 2026
5f64f5c
fix(cacbg): closelyHeldForm did not exclude КДА
ydimitrof Aug 6, 2026
4226ae3
feat(cacbg): the evidence ladder replaces the publish tiers
ydimitrof Aug 6, 2026
2a08744
feat(web): the surface explains the registry fact each link rests on
ydimitrof Aug 6, 2026
796903a
docs/ci: publish the rule, and put the decisions on a cadence
ydimitrof Aug 6, 2026
d50a723
fix(test): two fixtures that only passed because of local state
ydimitrof Aug 6, 2026
7a424bd
fix(tr): accept double-quoted attributes in the deed parser
ydimitrof Aug 6, 2026
299400e
fix(tr): anchor the ЕГН guard so a 13-digit ЕИК cannot abort the crawl
ydimitrof Aug 6, 2026
7f2e174
feat(tr): enforce the 35-day deed retention with a purge step
ydimitrof Aug 6, 2026
2327e62
feat(related-persons): implement the monotonicity gate ADR-0033 speci…
ydimitrof Aug 6, 2026
76ed3b2
docs(adr): record ADR-0033's amendments on the ADRs it amends
ydimitrof Aug 6, 2026
935ce1c
test(web): scope the missing-source assertion to the card that has none
ydimitrof Aug 6, 2026
f4d73dc
docs(adr): resolve the §5/§10 control-number gap in ADR-0033
ydimitrof Aug 6, 2026
2b06b76
fix(tr): survive a malformed numeric entity instead of killing the crawl
ydimitrof Aug 7, 2026
95d8c51
test(db): put the evidence seal gate under actual test pressure
ydimitrof Aug 7, 2026
0a61317
fix(db): withhold an unrecognised evidence seal instead of upgrading it
ydimitrof Aug 7, 2026
df76ae7
fix(cacbg): bound the seat matched_fact so a name cannot ride the seal
ydimitrof Aug 7, 2026
2d3c064
fix(seed): seal the dev fixture so /conflicts is not empty on a fresh DB
ydimitrof Aug 7, 2026
26333d8
fix(tr): bound both the response size and the erasure regex's backtra…
ydimitrof Aug 7, 2026
436dbc2
fix(cacbg): re-derive the deed path, and pin the duplicated joint-sto…
ydimitrof Aug 7, 2026
024c26f
feat(cacbg): add --emit-candidates so one job can bootstrap its own c…
ydimitrof Aug 7, 2026
ae661ac
ci(related-persons): run the register crawl inside the decision job
ydimitrof Aug 7, 2026
9555fbf
test(db): type the seal-gate fixture arrays explicitly
ydimitrof Aug 7, 2026
6265854
fix(ingest): запази отрицателната разлика при анекс, който намалява д…
todorkolev Aug 7, 2026
37e1258
build(deps): вдигни nanoid до 3.3.17+ (GHSA-2v37-7h3g-55p8) (#291)
todorkolev Aug 7, 2026
17f9472
fix(scripts): пестеливо качване на свързани лица и сверка след него (…
todorkolev Aug 7, 2026
ddaaacb
fix(etl): изтичащи потоци при четенията от ЕОП и сигнал за успешен кр…
todorkolev Aug 7, 2026
8139adb
fix(etl): never pair a full derive with a partial catch-up window (#270)
todorkolev Aug 8, 2026
8db751d
fix(etl): detect wrangler's SQLITE errors from stdout in safeD1 (#277)
todorkolev Aug 8, 2026
3e047c6
docs(scripts): подсигуряване, а не поправка на наблюдаван провал (#293)
todorkolev Aug 9, 2026
4f4ff60
test(scripts): покрий разпознаването на липсваща таблица в safeD1 (#295)
todorkolev Aug 9, 2026
73e055d
ci: забрани Co-Authored-By към агент, запази трейлърите с хора (#296)
todorkolev Aug 9, 2026
8689212
chore: махни проверката за трейлъри, правилото остава в AGENTS.md (#297)
todorkolev Aug 9, 2026
bf4fa97
fix(tr): refuse a seat confirmation when the declared period is unknown
ydimitrof Aug 11, 2026
466d002
fix(cacbg): give the monotonicity gate a path for the removals it san…
ydimitrof Aug 11, 2026
e5e8709
fix(ci,tr,db): close the remaining review findings on injection, purg…
ydimitrof Aug 11, 2026
aee1427
docs(adr): record the monthly cadence as a decision, not a YAML comment
ydimitrof Aug 11, 2026
36cdd9d
chore(deps): raise nanoid past GHSA-2v37-7h3g-55p8
ydimitrof Aug 11, 2026
4d21ba0
fix(cacbg): bar a listed АД whose seat carries neither comma nor dot
ydimitrof Aug 12, 2026
37a3db8
fix(cacbg): date a filing by its folder when the declared year is unr…
ydimitrof Aug 12, 2026
8e6f1dc
fix(cacbg): do not read an unresolvable holder column as an own stake
ydimitrof Aug 12, 2026
ce5b2ad
fix(tr): require the registry evidence to establish the COMPANY, not …
ydimitrof Aug 12, 2026
e9c17b9
feat(db): constrain the publishing-gate enums and fix the declaration…
ydimitrof Aug 12, 2026
021da5a
fix(db): gate the company-search badge on the evidence seal, and bind…
ydimitrof Aug 12, 2026
b8b4332
fix(web,ci): make the page prose family-aware and default the data jo…
ydimitrof Aug 12, 2026
2039b9f
test: cover the four unexercised audit axes, every ownership field, a…
ydimitrof Aug 12, 2026
da80eaf
fix(db): enforce the retrofit constraints with triggers, not a table …
ydimitrof Aug 12, 2026
013b177
fix(etl): свържи OCDS анексите с договорите през моста tender.id → УН…
cefothe Aug 12, 2026
1ab3dcd
test(db): закови gate-а за близнаци при анексите (#303)
todorkolev Aug 12, 2026
753dd4d
ci(deps): вдигни групата github-actions (5 обновявания) (#292)
dependabot[bot] Aug 12, 2026
daeb6e9
fix(etl): хващай стойност, въведена в стотинки (#298)
todorkolev Aug 12, 2026
426e2ef
fix(etl): хващай сбъркано число в анекс (стъпка >=10x при сбор >=5x) …
todorkolev Aug 12, 2026
e481baa
Merge upstream/main into feat/registry-evidence-links
ydimitrof Aug 13, 2026
ac15498
test: apply migration 0006 wherever the merged suites build a schema
ydimitrof Aug 13, 2026
24c8001
fix(db): leave migration 0003 untouched and let 0007 enforce for ever…
ydimitrof Aug 14, 2026
23a1939
test: assert the seal vocabulary with the production predicate, not a…
ydimitrof Aug 14, 2026
2813726
fix(web,db,docs): render the entry number, bind the join invariant, c…
ydimitrof Aug 14, 2026
9999ad5
fix(etl): поправяй и флагвай двойно броене в стойността на анекс (#307)
cefothe Aug 14, 2026
f8973a2
fix(ci): сверявай псевдонимите в сондата за колоните на анексите (#310)
todorkolev Aug 14, 2026
b17e70c
fix(etl): свържи разминатите по номер анекси през стойностна котва (#…
cefothe Aug 14, 2026
0a97059
fix(db,ci,docs): преномерирай миграциите на 0009/0010 и оправи комент…
todorkolev Aug 14, 2026
0d8be1e
refactor(db): преименувай променливите на миграциите 9/10 в тестовете
todorkolev Aug 14, 2026
90c2f99
Merge origin/main into feat/registry-evidence-links
todorkolev Aug 14, 2026
25d189e
fix(etl): хващай стотинки грешката и по прогнозата на позицията (#304)
todorkolev Aug 14, 2026
227bb4c
Merge remote-tracking branch 'origin/main' into pr309-work
todorkolev Aug 14, 2026
215123d
Merge origin/main into feat/registry-evidence-links (#304)
todorkolev Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ jobs:
pnpm --filter @sigma/web exec wrangler d1 execute "${SIGMA_D1_NAME:-sigma}" \
--config wrangler.deploy.jsonc --remote --yes \
--file ../../packages/db/migrations/0003_related_persons_foundation.sql
# 0006 attaches the Trade Register evidence seal (#279, ADR-0033). Both migrations are
# applied by name here because wrangler's migration ledger is empty on this D1 (the base
# schema was created out-of-band), so `d1 migrations apply` would collide on 0000.
pnpm --filter @sigma/web exec wrangler d1 execute "${SIGMA_D1_NAME:-sigma}" \
--config wrangler.deploy.jsonc --remote --yes \
--file ../../packages/db/migrations/0006_interest_link_evidence.sql
# The base schema was created out-of-band with `d1 execute --file`, so wrangler's migration
# ledger is empty and `d1 migrations apply` would collide on 0000. Probe the actual table
# instead. SQLite has no `ADD COLUMN IF NOT EXISTS`; a completion-marker table is created only
Expand Down
38 changes: 32 additions & 6 deletions .github/workflows/related-persons-data.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ name: Related-persons data foundation (build + ship)
# XML (fetch.mjs skips files already on disk) — it fills gaps, not a from-scratch rebuild unless the cache is cleared.

on:
# DAILY, and deliberately on the EXISTING workflow rather than a second one. #279 §9 asks for the
# publishing decisions to run on a cadence; duplicating the job would create a second ship path with
# its own copy of the credential guards, the D1-target guard and the ship floor — and the copy is the
# one nobody exercises. A scheduled run takes the same path as a manual one, with two differences:
# • it targets STAGING (the environment default below). Production stays manual, so the
# assertD1TargetAuthorized guard keeps a prod write a deliberate act.
# • `full_crawl` is empty and therefore false, so it never crawls the register — it re-runs the
# decisions over the cached corpus, which is exactly what #279 §9 means by a pure, zero-network
# decision. Registry lookups have their own workflow (related-persons-tr-refresh.yml).
# If the corpus cache has been evicted, extract yields nothing, the ship floor refuses, and the run
# fails loudly rather than wiping the surface.
schedule:
- cron: '0 3 * * *'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

B1+B2: This cron can never go green: the hydrate step recreates the work DB with only the 4 EOP tables, and nothing in this workflow restores scratch/trload.mjs dies at REFUSE TO LOAD: no Trade Register cache on every daily run. At the same time the monotonicity pre-wipe export reads interest_links from that same recreated DB → "no such table" → snapshot [] every run, i.e. the gate from 2327e62 protects nothing in CI. Both resolve in one place: export interest_links + interest_link_evidence too in the hydrate step (it already runs wrangler d1 export for 4 tables), and restore the TR index from cache/artifact/R2.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Потвърдено и по двете точки, и наистина се решават на едно място — макар и не съвсем на онова, което предлагате. Поправено в ae661ac (+ 024c26f).

B1. Работният поток за обхождане е премахнат; обхождането тече вътре в това задание. Възстановяване на scratch/tr от кеш/артефакт не приех — това е самото нарушение от B3, а суровите актове са и точно данните, които evidenceVerdict изисква (сравнява името на декларатора със самия текст на регистъра). Затова: без граница между двете. --emit-candidates (024c26f) зарежда списъка от нула на изпълнител без кеш, обхождането го използва, load.mjs чете scratch/tr от същия изпълнител, а суровите актове се трият изрично.

B2. Точно така — стъпката за захранване правеше rm -f backfill.sqlite и изнасяше само четирите EOP таблици, така че предварителният износ четеше „no such table", снимката беше [] при всеки ход, и гейтът от 2327e62 не пазеше нищо. Сега се изнасят и interest_links + interest_link_evidence — не за резолвера (load.mjs ги пресъздава), а именно като вход на гейта. Изнасят се отделно и липсата им се търпи: при първи ход 0003/0006 още не са приложени.

Едно нещо, което открих при поправката и което си струва да се отбележи, защото щеше да върне B2 през задния вход: зареждащият ход не бива да пипа истинската работна база. Стигането до списъка с кандидати минава през пресъздаване на таблиците, което изхвърля interest_links, а ходът никога не публикува — значи щеше да остави таблицата празна. Следващият истински ход щеше да прочете тази празна таблица като предишно публикувано състояние, да запише празна снимка, и гейтът, чиято единствена работа е да забележи изчезнало публикувано твърдение, щеше да минава безусловно завинаги. Затова --emit-candidates работи върху захвърляемо копие. Проверено с мутация: насочен към истинската база, проваля и двата теста.

Последица за графика, казана честно: решението не може да се преизчисли без актовете, актовете не преживяват изпълнителя, значи всеки ход, който решава, трябва и да обхожда — а ежедневно решение би значело ~400 заявки дневно към чужд регистър. Графикът стана месечен, не ежедневен, както го описват §9 и ADR-0033. Отбелязано е в коментар в самия работен поток. Връщането към ежедневно иска обхождането да издава присъди по (връзка, ЕИК), за да пресичат границата само булеви стойности — промяна в дизайна, нарочно невключена тук.

workflow_dispatch:
inputs:
environment:
Expand All @@ -34,7 +47,7 @@ permissions:

concurrency:
# Never let two data-foundation writes to the same environment overlap; do not cancel one in flight.
group: related-persons-data-${{ inputs.environment }}
group: related-persons-data-${{ inputs.environment || 'staging' }}
cancel-in-progress: false

jobs:
Expand All @@ -46,15 +59,15 @@ jobs:
# (stalled gov-server I/O) — fail rather than burn the slot. The raw corpus is cached (below) so a
# re-run resumes instead of re-crawling from scratch.
timeout-minutes: 300
environment: ${{ inputs.environment }}
environment: ${{ inputs.environment || 'staging' }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
SIGMA_D1_ID: ${{ secrets.SIGMA_D1_ID }}
SIGMA_D1_NAME: ${{ vars.SIGMA_D1_NAME }}
# The DECLARED intended environment — the independent anchor the ship guard checks the (name, id) pair
# against (T48), so a consistent-but-wrong pair can't wipe the wrong DB. See assertD1TargetAuthorized.
SIGMA_SHIP_ENV: ${{ inputs.environment }}
SIGMA_SHIP_ENV: ${{ inputs.environment || 'staging' }}
# Suppression (takedown) list keying — load.mjs fingerprints link-suppressions.jsonl under this salt and
# refuses to build if the list is non-empty but the salt is unset (fail-closed, ADR-0031). Without this
# the first suppression entry would break every ship; the key version guards a coordinated salt rotation.
Expand Down Expand Up @@ -93,14 +106,14 @@ jobs:
sigma-blue|sigma-green) IS_PROD_SLOT=1 ;;
*) IS_PROD_SLOT=0 ;;
esac
if [ "${{ inputs.environment }}" = "production" ]; then
if [ "${{ inputs.environment || 'staging' }}" = "production" ]; then
if [ "$IS_PROD_SLOT" != "1" ]; then
echo "::error::production requires SIGMA_D1_NAME to be a prod slot (sigma-blue|sigma-green), got '${SIGMA_D1_NAME}' — refusing to ship."
exit 1
fi
else
if [ "$IS_PROD_SLOT" = "1" ]; then
echo "::error::SIGMA_D1_NAME='${SIGMA_D1_NAME}' (a PRODUCTION slot) on a '${{ inputs.environment }}' run — a rebuild would WIPE prod. Set a non-production SIGMA_D1_NAME variable for this Environment."
echo "::error::SIGMA_D1_NAME='${SIGMA_D1_NAME}' (a PRODUCTION slot) on a '${{ inputs.environment || 'staging' }}' run — a rebuild would WIPE prod. Set a non-production SIGMA_D1_NAME variable for this Environment."
exit 1
fi
fi
Expand Down Expand Up @@ -185,6 +198,12 @@ jobs:
pnpm --filter @sigma/web exec wrangler d1 execute "$SIGMA_D1_NAME" \
--config wrangler.deploy.jsonc --remote --yes \
--file ../../packages/db/migrations/0003_related_persons_foundation.sql
# 0006 attaches the Trade Register evidence seal (#279, ADR-0033). Both migrations are
# applied by name here because wrangler's migration ledger is empty on this D1 (the base
# schema was created out-of-band), so `d1 migrations apply` would collide on 0000.
pnpm --filter @sigma/web exec wrangler d1 execute "$SIGMA_D1_NAME" \
--config wrangler.deploy.jsonc --remote --yes \
--file ../../packages/db/migrations/0006_interest_link_evidence.sql

- name: Ship свързани-лица tables → D1
# ship-related-persons.mjs shells out to `wrangler d1 execute` with cwd=apps/web. Two things it
Expand All @@ -198,7 +217,14 @@ jobs:
node scripts/wrangler-render.mjs apps/web/wrangler.jsonc
cp apps/web/wrangler.deploy.jsonc apps/web/wrangler.jsonc
export PATH="$GITHUB_WORKSPACE/apps/web/node_modules/.bin:$GITHUB_WORKSPACE/node_modules/.bin:$PATH"
node scripts/ship-related-persons.mjs --work-db data/work/backfill.sqlite --remote --yes
# --min-links is passed EXPLICITLY, not left at its default of 50. Under #279 the surface is
# measured at ~329 links, so 50 no longer protects anything: a partially-restored Trade
# Register cache yields roughly 80 published links, which clears a floor of 50, ships a
# decimated surface and wipes the rest. The loader's coverage gate is the first defence and
# this is the second (ADR-0033 decision 7). Lower it deliberately, in a PR, if a genuinely
# smaller surface is ever expected.
node scripts/ship-related-persons.mjs --work-db data/work/backfill.sqlite --remote --yes \
--min-links 250

- name: Reindex search projection → officials searchable (same batch the cron runs)
# ship writes ONLY the свързани-лица domain tables (interest_links, persons, …), NOT search_index.
Expand Down
119 changes: 119 additions & 0 deletions .github/workflows/related-persons-tr-refresh.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: Related-persons — Trade Register cache refresh (monthly)

# Refreshes the Търговски регистър deed cache the publishing decision rests on (#279, ADR-0033).
#
# WHY THIS IS ITS OWN WORKFLOW, separate from the decision run: this is the only step in the project
# that touches a public register at volume, and it is the only one with a network budget. The decisions
# themselves are a pure function of declarations, cached deeds and contracts — they must be able to run
# daily without ever calling the register. Splitting them keeps that true by construction.
#
# It SHIPS NOTHING. It writes only the cache under scratch/tr/, which is git-ignored and never leaves
# the runner except as an artifact. A run that fails leaves the previous cache intact.
#
# It also PURGES: raw deeds past the 35-day retention (ADR-0033 decision 5) are deleted at the end of
# every run, including one a 429 cut short. Retention is not the same knob as freshness — --max-age-days
# decides what gets re-requested, --retention-days decides what stops being kept — and the default is
# the ADR's 35 days, so the rail holds even when the workflow passes neither.
#
# Pace and refusals live in scripts/tr/fetch-deeds.mjs and are not configurable here on purpose:
# 1 request / 3 s, sequential, a closed candidate set, and a 429 ends the run (exit 2) without marking
# anything. Spec §3.3 permits a bounded per-ЕИК lookup and forbids bulk scraping; the limiter is the
# operator's only way to state a rate preference, so we do not tune around it.

on:
schedule:
# 02:00 UTC on the 1st. Monthly, per #279 §9 — a deed changes rarely, and every extra pass is
# ~400 requests against somebody else's service for almost no new information.
- cron: '0 2 1 * *'
workflow_dispatch:
inputs:
max_age_days:
description: Re-fetch deeds older than this many days (default 30). Use a large value to force a full refresh.
type: string
default: '30'
limit:
description: Stop after this many lookups (blank = all pending). Bound a first run while watching for a 429.
type: string
default: ''

concurrency:
# Never two crawls at once: they would double the request rate against the register, which is the one
# thing the pacing exists to prevent. Queue rather than cancel — a cancelled crawl wastes its progress.
group: tr-cache-refresh
cancel-in-progress: false

permissions:
contents: read

jobs:
refresh:
runs-on: ubuntu-latest
# ~400 candidates at 1 per 3 s is ~20 minutes; the ceiling allows retries and a slower day without
# letting a hung run sit for the 6-hour default.
timeout-minutes: 60
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: pnpm

# The cache is the whole point of the workflow: without restoring it, every run would re-fetch
# every deed and turn a 400-request refresh into a 400-request full crawl each month.
- name: Restore the deed cache
id: cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.0.2
with:
path: scratch/tr

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

B3 (privacy): path: scratch/tr also caches deeds/*.json — third-party co-owner/manager names, exactly the data decision 5 promises a 35-day retention for. The artifact step below correctly uploads only the index; this step makes no such distinction. purgeExpired deletes on the runner, but old cache entries live on (LRU/7-days-unused eviction is a capacity policy, not retention, and the restore-keys: prefix keeps the chain alive). Cache only scratch/tr/tr-cache.sqlite; if the raw deeds must survive across runs — R2 with a lifecycle rule (the ADR-0006 model).

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Потвърдено, и то беше нарушение на собственото обещание на ADR-0033 решение 5. Поправено в ae661ac.

Права сте и за причината: purgeExpired тече на изпълнителя, но по-старите записи в кеша живеят в хранилището на GitHub по веригата на restore-keys, а изгонването там е политика за капацитет, не задържане.

Не приех обаче „кеширай само индекса" — това чупи решаващия ход: load.mjs чете суровия JSON, защото evidenceVerdict се нуждае от имената за подмножественото съвпадение, а индексът нарочно не пази нито едно. Тоест суровите актове наистина трябва да пресекат границата, което е и ограничението, в което опира B1.

Затова изборът е първата от вашите две алтернативи в друга форма: никаква граница. Обхождане и решение в едно задание, кешира се само scratch/cacbg/raw, scratch/tr не се запазва никога, а суровите актове се трият изрично в if: always() стъпка — за да не може по-късно добавено качване на артефакт да ги изнесе. R2 с правило за жизнен цикъл остава разумният вариант, ако някога се наложи да преживяват ход; днес не се налага.

Последица, която да е казана честно: решението не може да се преизчисли без актовете, а актовете не преживяват изпълнителя — значи всеки ход, който решава, трябва и да обхожда. Ежедневно решение би значело ~400 заявки дневно към чужд регистър. Затова графикът стана месечен, а не ежедневен, както го описват §9 и ADR-0033. Връщането към ежедневно иска обхождането да издава присъди по (връзка, ЕИК), за да пресичат границата само булеви стойности — това е промяна в дизайна и нарочно не е вкарана тук.

key: tr-deed-cache-${{ github.run_id }}
restore-keys: |
tr-deed-cache-

- run: pnpm install --frozen-lockfile

# The closed candidate set. It is produced by the decision run (load.mjs writes it), so a cache
# refresh can never invent an ЕИК to look up — it can only refresh what the corpus already
# resolved. Absent on a first run, in which case there is nothing to refresh yet.
- name: Check for a candidate list
id: candidates
run: |
if [ -s scratch/cacbg/staging/candidate-eiks.txt ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

B1 (the other half of the deadlock): candidate-eiks.txt is written by load.mjs on the other workflow's runner and is never persisted — this check is false on every run, forever, and the job is a permanently green no-op. The daily job waits on a deed cache this workflow will never produce; this one waits on a candidate file the daily job will never deliver. Persist the list (cache/artifact) or derive the candidates directly from D1.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Потвърдено — и двете половини на блокировката бяха точно както ги описвате. Поправено в ae661ac (+ 024c26f).

Този работен поток е премахнат. Обхождането на регистъра вече тече вътре в заданието на решенията.

Не може да се поправи с пренасяне на файлове между ходовете, защото решението се нуждае от суровите актове (evidenceVerdict сравнява името на декларатора със самия текст на регистъра — точно данните, които индексът отказва да съхранява), а те не бива да се запазват между ходове. Затова: едно задание, --emit-candidates (024c26f) го зарежда от нула, и суровите актове се трият изрично накрая.

--emit-candidates работи върху захвърляемо копие на работната база, и това е коректност, а не спретнатост — вж. отговора за B2.

echo "count=$(wc -l < scratch/cacbg/staging/candidate-eiks.txt)" >> "$GITHUB_OUTPUT"
echo "ok=true" >> "$GITHUB_OUTPUT"
else
echo "No candidate-eiks.txt — run the decision workflow first. Nothing to refresh."
echo "ok=false" >> "$GITHUB_OUTPUT"
fi

- name: Refresh deeds
if: steps.candidates.outputs.ok == 'true'
env:
MAX_AGE: ${{ inputs.max_age_days || '30' }}
LIMIT: ${{ inputs.limit }}
run: |
set -euo pipefail
ARGS=(--eiks-file scratch/cacbg/staging/candidate-eiks.txt --max-age-days "$MAX_AGE")
[ -n "$LIMIT" ] && ARGS+=(--limit "$LIMIT")
set +e
node scripts/tr/fetch-deeds.mjs "${ARGS[@]}"
code=$?
set -e
# Exit 2 is the rate limiter, and it is NOT a build failure: the run stopped politely, marked
# nothing, and the partial cache is valid and resumable. Failing the workflow here would train
# everyone to ignore a red X on the one signal that means "the register asked us to stop".
if [ "$code" -eq 2 ]; then
echo "::warning::The register rate-limited us; the run stopped and the cache is resumable."
exit 0
fi
exit "$code"

- name: Publish the cache index as an artifact (no raw deeds — they carry third-party data)
if: always() && steps.candidates.outputs.ok == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: tr-cache-index
# The SQLite index only. scratch/tr/deeds/*.json holds owner and manager names and company
# addresses (ADR-0033 decision 5) and must not leave the runner.
path: scratch/tr/tr-cache.sqlite
retention-days: 7
13 changes: 8 additions & 5 deletions .github/workflows/scripts-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ jobs:
# so no `pnpm install` is required.
- run: node --test scripts/*.test.mjs

# свързани-лица CACBG pipeline tests (parse/classify/load/audit/tr-census/extract). These use
# node:sqlite (DatabaseSync) and import the shared companyNameKey .ts via the register-ts resolve
# hook, so they need Node 24 (node:sqlite + native TS type-stripping, no experimental flag) rather
# than the Node 22 above. The libel-critical resolution logic gates merges here.
# свързани-лица pipeline tests — the CACBG leg (parse/classify/load/audit/extract) and the
# Търговски регистър leg (scripts/tr: ЕИК checksum, HTTP client, deed cache). These use node:sqlite
# (DatabaseSync) and import the shared companyNameKey .ts via the register-ts resolve hook, so they
# need Node 24 (node:sqlite + native TS type-stripping, no experimental flag) rather than the Node 22
# above. The libel-critical resolution logic gates merges here.
cacbg:
runs-on: ubuntu-latest
timeout-minutes: 10
Expand All @@ -47,4 +48,6 @@ jobs:
# parse.mjs imports fast-xml-parser (a workspace dep), so this job needs node_modules — unlike
# the plain-node scripts above. Install before the scraper tests run.
- run: pnpm install --frozen-lockfile
- run: node --import ./scripts/cacbg/register-ts.mjs --test scripts/cacbg/*.test.mjs
# Both legs, or the glob silently excludes a whole module: scripts/tr/*.test.mjs matched NO job
# until this line existed, so every test in it would have been decorative.
- run: node --import ./scripts/cacbg/register-ts.mjs --test scripts/cacbg/*.test.mjs scripts/tr/*.test.mjs
16 changes: 16 additions & 0 deletions apps/web/app/components/ConflictCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
officialHref,
partitionContracts,
relationLabel,
registryEvidenceLabel,
temporalLabel,
} from '../lib/conflicts';

Expand Down Expand Up @@ -181,6 +182,21 @@ function ConflictCard({
)}
</dd>
</div>
{/* The Trade Register fact the link's identity rests on (#279, ADR-0033). This is what makes
„every shown link explains itself" true rather than a promise: a reader can open the same act
we read and check it. The wording is careful — the register records a ROLE, it does not
certify the ownership claim, which comes from the official's own declaration. */}
<div className="cc-stat">
<dt>Регистър</dt>
<dd>
<ExternalEikLink eik={l.eik} />
<span className="small muted cc-evidence">
{registryEvidenceLabel(l)}
{l.registryEntryDate ? ` · вписване ${l.registryEntryDate}` : ''}
{l.registryLookupDate ? ` · справка ${l.registryLookupDate}` : ''}
</span>
</dd>
</div>
</dl>

{l.contractCount > 0 && (
Expand Down
37 changes: 36 additions & 1 deletion apps/web/app/lib/conflicts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
partitionContracts,
relationLabel,
temporalLabel,
registryEvidenceLabel,
} from './conflicts';

function link(over: Partial<ConflictLink> = {}): ConflictLink {
Expand All @@ -43,7 +44,13 @@ function link(over: Partial<ConflictLink> = {}): ConflictLink {
contemporaneousValueEur: 40_000_000,
firstContractYear: '2021',
lastContractYear: '2024',
sourceUrl: 'https://register.cacbg.bg/2024/i.xml',
sourceUrl: 'https://register.cacbg.bg/2024/x.xml',
// #279: a link only reaches the DTO when its identity rests on a Trade Register fact.
evidenceKind: 'document',
registryRole: 'owner',
registryEntryNumber: '20110502101007',
registryEntryDate: '2011-05-02',
registryLookupDate: '2026-08-05',
...over,
};
}
Expand Down Expand Up @@ -523,3 +530,31 @@ describe('authorityShareDisplay', () => {
});
});
});

describe('registryEvidenceLabel', () => {
// The wording is load-bearing. The register records a ROLE; it does not certify that the official owns
// anything — that claim comes from their own declaration and is rendered separately. A label that said
// „собственик според ТР" would assert something the evidence does not support (ADR-0033 decision 2).
it('reports what the act records, never an ownership conclusion', () => {
expect(registryEvidenceLabel({ evidenceKind: 'document', registryRole: 'owner' })).toBe(
'лицето е вписано като съдружник/собственик',
);
expect(registryEvidenceLabel({ evidenceKind: 'document', registryRole: 'manager' })).toBe(
'лицето е вписано като управител',
);
});

it('a seat/ЕИК confirmation claims identity, not a registry role', () => {
// „Потвърдено" means the COMPANY was identified from something the official declared — nobody was
// found in the act, so the label must not imply anyone was.
const label = registryEvidenceLabel({ evidenceKind: 'confirmed', registryRole: null });
expect(label).toBe('самоличност, потвърдена по декларирани данни');
expect(label).not.toMatch(/вписан/);
});

it('never renders the word „собственик" for a mere confirmation', () => {
expect(registryEvidenceLabel({ evidenceKind: 'confirmed', registryRole: 'owner' })).not.toMatch(
/собственик/,
);
});
});
18 changes: 18 additions & 0 deletions apps/web/app/lib/conflicts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ const RELATION_LABEL: Record<string, string> = {
};

/** Bulgarian label for a declared relation. Unknown values pass through — never invent a stronger claim. */
/**
* How the company's identity was established, in the register's own terms (#279, ADR-0033).
*
* Deliberately does NOT say the official owns anything: „вписан съдружник/собственик" reports what the
* act RECORDS, while the ownership claim itself comes from the official's own declaration and is
* rendered separately as „дялово участие". „Потвърдено" means the company was identified by a fact the
* official declared — the seat or the ЕИК — not that anybody was found in the act.
*/
export function registryEvidenceLabel(l: {
evidenceKind: 'document' | 'confirmed';
registryRole: 'owner' | 'manager' | null;
}): string {
if (l.evidenceKind === 'confirmed') return 'самоличност, потвърдена по декларирани данни';
return l.registryRole === 'manager'
? 'лицето е вписано като управител'
: 'лицето е вписано като съдружник/собственик';
}

export function relationLabel(relation: string): string {
return RELATION_LABEL[relation] ?? relation;
}
Expand Down
Loading