Skip to content
Draft
Show file tree
Hide file tree
Changes from 29 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
139 changes: 131 additions & 8 deletions .github/workflows/related-persons-data.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,38 @@ 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:
# MONTHLY, 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.
#
# WHY MONTHLY AND NOT DAILY, which is what §9 and ADR-0033 describe. A decision cannot be recomputed
# without the raw deeds — evidenceVerdict's strongest rung matches the declarant's name against the
# register's own text — and the raw deeds must not be persisted between runs: they carry the names and
# addresses of co-owners and managers who hold no public office, and an Actions cache entry lives on
# GitHub's storage under a restore-keys chain where ADR-0033 decision 5's 35-day retention cannot reach
# it. So the deeds live and die with one runner, and every run that decides must also crawl. A daily
# decision would therefore mean ~400 daily requests against somebody else's register, which is what
# spec §3.3 exists to prevent — so the cadence follows the deeds, not the other way round.
#
# Restoring a daily decision needs the crawl to emit the per-(link, ЕИК) match verdict, so only
# booleans cross a run boundary and nothing has to hold a name. That is a design change, not a
# schedule change, and it is deliberately not folded in here.
#
# 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 CACBG corpus — it re-runs the
# decisions over the cached corpus, which is exactly what #279 §9 means by a pure, zero-network
# decision. Registry lookups are the one exception and run from inside this job on the 1st of the
# month (see „Decide whether to hit the register"): they cannot be a separate workflow, because the
# raw deeds they fetch carry third-party names and must not be persisted between runners.
# 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:
# 03:00 UTC on the 1st. See the note above: the decision and the crawl are one pass, so the decision
# cadence is the crawl cadence — #279 §9's monthly registry rhythm.
- cron: '0 3 1 * *'
workflow_dispatch:
inputs:
environment:
Expand All @@ -27,34 +59,43 @@ on:
description: Crawl the CACBG corpus (slow). RESUMES from the cached raw XML — fetch.mjs skips files already on disk, so this fills gaps rather than re-fetching from scratch (clear the cache for a true rebuild). Off = extract from the existing raw cache without crawling.
type: boolean
default: false
tr_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'
tr_limit:
description: Stop after this many registry lookups (blank = all pending). Bound a first run while watching for a 429.
type: string
default: ''

# Least-privilege: this workflow only reads the repo; it authenticates to Cloudflare with a scoped token.
permissions:
contents: read

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:
build-and-ship:
runs-on: ubuntu-latest
# A from-scratch corpus crawl of the whole register is the long pole (all year-folders, politely
# throttled) and must fit in ONE job with extract/resolve/audit/ship after it — 120 was too tight and
# timed out mid-crawl. 300 leaves headroom under GitHub's 6-hour hard cap. A run past this is wedged
# throttled) and must fit in ONE job with the registry lookups, extract/resolve/audit/ship after it —
# 120 was too tight and timed out mid-crawl. The Търговски регистър pass adds ~20 minutes on top
# (~400 candidates at 1 request / 3 s). 300 leaves headroom under GitHub's 6-hour hard cap. A run past this is wedged
# (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 +134,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 @@ -151,11 +192,29 @@ jobs:
work="$GITHUB_WORKSPACE/data/work"
mkdir -p "$work"
rm -f "$work/eop.sql" "$work/backfill.sqlite"
# interest_links + interest_link_evidence come along too, and NOT for the resolver's benefit —
# load.mjs drops and rebuilds them. They are the input to the monotonicity gate: load.mjs
# exports the currently-published set BEFORE the wipe, and audit.mjs fails the run on a link
# that was published last time, under an unchanged rules_version, and is not published now.
# Without them the work DB starts with no interest_links at all, the pre-wipe export reads
# „no such table", the snapshot is [] on every run, and the gate protects nothing.
# They are absent on a first run (0003/0006 not yet applied), which is why they are exported
# separately and tolerated as missing rather than folded into the parity loop below.
pnpm --filter @sigma/web exec wrangler d1 export "$SIGMA_D1_NAME" \
--config wrangler.deploy.jsonc --remote -y \
--table bidders --table contracts --table tenders --table authorities \
--output "$work/eop.sql"
sqlite3 "$work/backfill.sqlite" < "$work/eop.sql"
if pnpm --filter @sigma/web exec wrangler d1 export "$SIGMA_D1_NAME" \
--config wrangler.deploy.jsonc --remote -y \
--table interest_links --table interest_link_evidence \
--output "$work/prior-links.sql"; then
sqlite3 "$work/backfill.sqlite" < "$work/prior-links.sql"
echo " prior surface: $(sqlite3 "$work/backfill.sqlite" \
"SELECT COUNT(*) FROM interest_links WHERE status='published';") published link(s)"
else
echo "::notice::No prior interest_links on this D1 (first run) — the monotonicity gate has no baseline yet."
fi
for t in bidders contracts tenders authorities; do
local_n=$(sqlite3 "$work/backfill.sqlite" "SELECT COUNT(*) FROM $t;")
remote_json=$(pnpm --filter @sigma/web exec wrangler d1 execute "$SIGMA_D1_NAME" \
Expand All @@ -169,9 +228,60 @@ jobs:
fi
done

# ── Trade Register lookups ────────────────────────────────────────────────────────────────────
# In THIS job, deliberately, and not in a workflow of their own. The decision run needs the raw
# deeds (evidenceVerdict matches declarant names against the register's own text, which is exactly
# the data the index refuses to store), and those deeds carry the names and addresses of co-owners
# and managers who hold no public office. Handing them between runners means persisting them —
# an Actions cache entry survives on GitHub's storage under a restore-keys chain, where the 35-day
# retention ADR-0033 decision 5 promises cannot reach it. Same runner, same job, deleted with it.
#
# The previous split also could not work on its own terms: the crawl job gated on a candidate list
# only the decision job produced, the decision job refused without a cache only the crawl job
# produced, and neither persisted its half. Both were permanently green no-ops.
- name: Emit the candidate ЕИК list for the crawler
run: node --import ./scripts/cacbg/register-ts.mjs scripts/cacbg/load.mjs --emit-candidates

# ALWAYS, on every run that reaches here — not conditionally. The deeds do not survive the runner,
# so „decide without crawling" is not a state this job can be in: load.mjs's coverage gate below
# would refuse the empty cache and the run would fail. Skipping the crawl is only meaningful once
# the crawl emits verdicts rather than deeds (see the note on the schedule above).
#
# 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 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.
- name: Refresh the Trade Register deed cache
env:
MAX_AGE: ${{ inputs.tr_max_age_days || '30' }}
LIMIT: ${{ inputs.tr_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. It is not a licence to publish either — load.mjs's
# coverage gate below refuses a partial cache on its own, which is where that decision belongs.
if [ "$code" -eq 2 ]; then
echo "::warning::The register rate-limited us; the crawl stopped and the cache is resumable."
exit 0
fi
exit "$code"

- name: Resolve → build domain in a work sqlite (libel gate must pass)
run: node --import ./scripts/cacbg/register-ts.mjs scripts/cacbg/load.mjs

# The raw deeds are third-party personal data and exist only to produce booleans. They are deleted
# with the runner regardless; deleting them explicitly means a failure AFTER this point cannot
# leave them sitting in a job artifact or a debug upload someone adds later.
- name: Delete the raw deeds
if: always()
run: rm -rf scratch/tr/deeds

- name: Audit published links (fails the run on any libel-invariant violation)
run: node --import ./scripts/cacbg/register-ts.mjs scripts/cacbg/audit.mjs

Expand All @@ -185,6 +295,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 +314,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
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
Loading