Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 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
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
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
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
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
20 changes: 20 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,26 @@ 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
# 0009 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/0009_interest_link_evidence.sql
# 0010 owns the publishing-gate enforcement for EVERY database (#279 §2) — fresh and already
# deployed alike. It is deliberately NOT declared in 0003: that migration is already applied
# everywhere, `CREATE TABLE IF NOT EXISTS` never revisits an existing table, and the ship step
# wipes ROWS, not definitions — so an in-place CHECK would exist only on databases built after
# the edit, and be absent on exactly the database that serves the site. Enforced with BEFORE
# INSERT/UPDATE triggers, NOT a table rebuild: the create-copy-drop-rename route would expose
# the foreign keys and strip every evidence seal, emptying the public surface until the next
# monthly run (see the migration header). `control_hash` stays NULLABLE by design — the register
# omits it on some declarations; the natural-key index folds those with COALESCE instead.
# Idempotent: every statement is `IF NOT EXISTS` over a converging definition, so re-applying it
# on each deploy is a no-op. Verified by three consecutive applications, gate still enforcing.
pnpm --filter @sigma/web exec wrangler d1 execute "${SIGMA_D1_NAME:-sigma}" \
--config wrangler.deploy.jsonc --remote --yes \
--file ../../packages/db/migrations/0010_publishing_gate_constraints.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
162 changes: 152 additions & 10 deletions .github/workflows/related-persons-data.yml

Large diffs are not rendered by default.

13 changes: 8 additions & 5 deletions .github/workflows/scripts-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ jobs:
# ubuntu-latest image, and the test fails loudly (never skips) if it is ever absent.
- 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 @@ -49,4 +50,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
21 changes: 21 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,26 @@ 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}` : ''}
{/* The entry NUMBER, not just its date: a date does not identify a record, and this is
what a reader types to find the same act we read. Rendered only when present —
a seat/ЕИК confirmation cites no act entry, and an empty „№" would read as missing
data rather than as an inapplicable field. */}
{l.registryEntryNumber ? ` · № ${l.registryEntryNumber}` : ''}
{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(
/собственик/,
);
});
});
35 changes: 35 additions & 0 deletions apps/web/app/lib/conflicts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,45 @@ 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;
}

/**
* How to describe a PAGE's set of links in prose (#279 §2.6). The card labels above are already
* family-aware; the surrounding page copy was not, and asserted „собствен дял" — an OWN stake — above
* cards that correctly read „свързано лице". On a family-only page that is a false claim about the named
* official, and it is the second source of truth the card-label fix set out to remove.
*
* Derived from the links themselves rather than passed in, so a page cannot describe a set it isn't
* rendering. Mixed sets get the neutral wording: it is the only phrasing true of every card.
*/
export function declaredStakeNoun(links: { relation: string }[]): string {
const anyFamily = links.some((l) => l.relation === 'related');
const anySelf = links.some((l) => l.relation !== 'related');
if (anyFamily && !anySelf) return 'дял на свързано лице';
if (anyFamily && anySelf) return 'деклариран дял — собствен или на свързано лице';
return 'собствен дял';
}

// Defense in depth: the slug is base64url and the ЕИК numeric today (so encoding is a no-op), but if either
// assumption ever drifts, an un-escaped `/`, `?` or `#` would break routing and the cache key. Escape the
// dynamic segments unconditionally (ydimitrof #226, conflicts.ts).
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/routes/conflict.company.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { ConflictCards } from '../components/ConflictCards';
import { publicCache } from '../lib/cache';
import { withDbRetry } from '../lib/retry';
import { seoMeta } from '../lib/meta';
import { companyProfileHref } from '../lib/conflicts';
import { companyProfileHref, declaredStakeNoun } from '../lib/conflicts';

// Officials with a published declared interest in one winner (by ЕИК). Reads interest_links only. 404 when
// no official has a published link to this company — never an empty page under a company's name.
Expand Down Expand Up @@ -59,7 +59,7 @@ export default function ConflictCompany({ loaderData }: Route.ComponentProps) {
</>
}
title={company}
lede={`Длъжностни лица, декларирали собствен дял в това дружество пред КПКОНПИ. ${count(links.length)} ${plural(links.length, 'връзка', 'връзки')} — всяка е точно съвпадение по фирмено име.`}
lede={`Длъжностни лица, декларирали ${declaredStakeNoun(links)} в това дружество пред КПКОНПИ. ${count(links.length)} ${plural(links.length, 'връзка', 'връзки')} — всяка почива на проверим факт от Търговския регистър.`}
/>

<Callout titleAs="h2" title="Източник и обхват">
Expand Down
Loading